diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed596d811..f93b256d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: java-version: '25' publish: true project-to-publish: "API:publish" - discord-avatar-url: "https://groupez.dev/storage/images/253.png" + discord-avatar-url: "https://groupez.dev/storage/images/resources/icons/253.webp" artifact-glob: "target/*.jar" changelog-exclude-pattern: '^[a-f0-9]+\s+(build|chore)\(deps\)' secrets: diff --git a/.gitignore b/.gitignore index 8d7a4fbe4..33cd6d56e 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ build-and-deploy.sh GEMINI.md TODO.md -docs/triage-issues.md +/docs/* +!/docs/live-sync-plan.md diff --git a/API/build.gradle.kts b/API/build.gradle.kts index bccc0c4d0..aa4fa846e 100644 --- a/API/build.gradle.kts +++ b/API/build.gradle.kts @@ -1,10 +1,20 @@ plugins { alias(libs.plugins.groupez.publish) + `java-test-fixtures` } dependencies{ compileOnly(libs.adventure.api) compileOnly(libs.adventure.text.minimessage) + + testImplementation(libs.paper.api) + testFixturesCompileOnly(libs.paper.api) +} + +// The test fixtures are shared with the tests of other modules, they are not published. +(components["java"] as AdhocComponentWithVariants).apply { + withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() } + withVariantsFromConfiguration(configurations["testFixturesRuntimeElements"]) { skip() } } rootProject.extra.properties["sha"]?.let { sha -> @@ -15,7 +25,6 @@ tasks { shadowJar { relocate("com.tcoded.folialib", "fr.maxlego08.menu.hooks.folialib") relocate("fr.traqueur.currencies", "fr.maxlego08.menu.hooks.currencies") - relocate("de.tr7zw.changeme.nbtapi", "fr.maxlego08.menu.hooks.nbtapi") relocate("com.cryptomorin.xseries", "fr.maxlego08.menu.hooks.xseries") relocate("fr.maxlego08.sarah", "fr.maxlego08.menu.hooks.sarah") relocate("net.objecthunter.exp4j", "fr.maxlego08.menu.hooks.exp4j") diff --git a/API/src/main/java/fr/maxlego08/menu/api/ButtonManager.java b/API/src/main/java/fr/maxlego08/menu/api/ButtonManager.java index cf4d5b7cd..2f5f787bf 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/ButtonManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/ButtonManager.java @@ -36,21 +36,30 @@ public interface ButtonManager { * Registers a new {@link ButtonLoader} responsible for button creation. * * @param button The {@link ButtonLoader} instance to register. + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#BUTTON}: + * {@code registries.writable(Registries.BUTTON).register(plugin, loader, loader.aliases())}, which + * throws on a name already taken instead of logging it. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void register(@NotNull ButtonLoader button); /** * Unregisters an existing {@link ButtonLoader}, ceasing its button creation responsibilities. * * @param button The {@link ButtonLoader} instance to unregister. + * @deprecated use {@code registries.writable(Registries.BUTTON).unregister(loader.key())}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void unregister(@NotNull ButtonLoader button); /** * Unregisters all {@link ButtonLoader} instances linked to a specific plugin. * * @param plugin The plugin whose loaders should be unregistered. + * @deprecated a plugin's button types are removed when it disables; to remove them earlier use + * {@code registries.writable(Registries.BUTTON).unregisterAll(plugin)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void unregisters(@NotNull Plugin plugin); /** @@ -66,8 +75,11 @@ public interface ButtonManager { * * @param plugin The plugin whose loaders are to be retrieved. * @return A collection of {@link ButtonLoader} instances or an empty collection if none are found. + * @deprecated read {@link fr.maxlego08.menu.api.registry.Registries#BUTTON} and its + * {@link fr.maxlego08.menu.api.registry.KeyedRegistry#owner(net.kyori.adventure.key.Key) owners}. */ @NotNull + @Deprecated(since = "1.1.2.1", forRemoval = true) Collection getLoaders(@NotNull Plugin plugin); /** @@ -83,15 +95,21 @@ public interface ButtonManager { * Registers a new {@link PermissibleLoader} responsible for permissible creation. * * @param permissibleLoader The {@link PermissibleLoader} instance to register. + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#PERMISSIBLE}: + * {@code registries.writable(Registries.PERMISSIBLE).register(plugin, loader)}, which throws on a + * name already taken instead of logging it. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void registerPermissible(@NotNull PermissibleLoader permissibleLoader); /** * Retrieves a mapping of permissible keys to their associated {@link PermissibleLoader} instances. * - * @return A map linking permissible keys to their corresponding {@link PermissibleLoader} instances. + * @return A copy of the registered types, by their lowercased name. + * @deprecated read {@link fr.maxlego08.menu.api.registry.Registries#PERMISSIBLE}. */ @NotNull + @Deprecated(since = "1.1.2.1", forRemoval = true) Map getPermissibles(); /** @@ -107,7 +125,11 @@ public interface ButtonManager { * Registers a new {@link ActionLoader} responsible for action creation. * * @param actionLoader The {@link ActionLoader} instance to register. + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#ACTION}: + * {@code registries.writable(Registries.ACTION).register(plugin, loader, loader.aliases())}, which + * throws on a name already taken instead of logging it. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void registerAction(@NotNull ActionLoader actionLoader); /** diff --git a/API/src/main/java/fr/maxlego08/menu/api/ComponentsManager.java b/API/src/main/java/fr/maxlego08/menu/api/ComponentsManager.java index 92c14eb1e..3510ff216 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/ComponentsManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/ComponentsManager.java @@ -1,6 +1,5 @@ package fr.maxlego08.menu.api; -import fr.maxlego08.menu.api.exceptions.ItemComponentAlreadyRegisterException; import fr.maxlego08.menu.api.loader.ItemComponentLoader; import org.jetbrains.annotations.NotNull; @@ -12,11 +11,14 @@ public interface ComponentsManager { void initializeDefaultComponents(MenuPlugin plugin); /** - * Register a new ItemComponentLoader. + * Register a new ItemComponentLoader. A name already taken is logged and the loader is not registered. * @param loader The loader to register - * @throws ItemComponentAlreadyRegisterException if a loader with the same name is already registered + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#ITEM_COMPONENT}: + * {@code registries.writable(Registries.ITEM_COMPONENT).register(plugin, loader, loader.aliases())}, + * which throws on a name already taken. **/ - void registerComponent(@NotNull ItemComponentLoader loader) throws ItemComponentAlreadyRegisterException; + @Deprecated(since = "1.1.2.1", forRemoval = true) + void registerComponent(@NotNull ItemComponentLoader loader); /** * Get an ItemComponentLoader by its name. diff --git a/API/src/main/java/fr/maxlego08/menu/api/Inventory.java b/API/src/main/java/fr/maxlego08/menu/api/Inventory.java index f09010fe0..5fc9264d5 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/Inventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/Inventory.java @@ -13,6 +13,8 @@ import fr.maxlego08.menu.api.utils.OpenWithItem; import fr.maxlego08.menu.api.utils.Placeholders; import fr.maxlego08.menu.api.utils.SectionSerializable; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryType; @@ -31,7 +33,19 @@ * Documentation: Inventory documentation *

This class encapsulates information about an inventory, including its size, buttons, patterns, and associated requirements.

*/ -public interface Inventory extends SectionSerializable { +public interface Inventory extends Keyed, SectionSerializable { + + /** + * Returns the key of the inventory: the name of its plugin and its file name, lowercased, with + * every character a key cannot hold replaced by "_". + * + * @return the key + * @see ZMenuKeys#fileKey(String, String) + */ + @Override + default @NotNull Key key() { + return ZMenuKeys.fileKey(this.getPlugin().getName(), this.getFileName()); + } /** * Returns the size of the inventory. @@ -91,6 +105,9 @@ public interface Inventory extends SectionSerializable { /** * Returns the name of the file associated with the inventory. * + *

Use it to display or store the inventory, not to identify it: the case is kept, so it can + * differ from {@link #key()}. Compare and look up inventories with {@link #key()}.

+ * * @return The file name. This name is the name of the configuration file. */ String getFileName(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java b/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java index 212763cf0..622a06620 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java @@ -1,6 +1,7 @@ package fr.maxlego08.menu.api; import com.tcoded.folialib.impl.PlatformScheduler; +import fr.maxlego08.menu.api.animation.slot.AnimationManager; import fr.maxlego08.menu.api.button.Button; import fr.maxlego08.menu.api.button.ButtonOption; import fr.maxlego08.menu.api.button.GenericPaginationButton; @@ -11,13 +12,19 @@ import fr.maxlego08.menu.api.event.events.ButtonLoaderRegisterEvent; import fr.maxlego08.menu.api.exceptions.InventoryException; import fr.maxlego08.menu.api.font.FontImage; +import fr.maxlego08.menu.api.itemstack.ItemStackService; import fr.maxlego08.menu.api.itemstack.ItemStackSimilar; import fr.maxlego08.menu.api.itemstack.MenuItemStackConversion; import fr.maxlego08.menu.api.loader.MaterialLoader; +import fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader; import fr.maxlego08.menu.api.pagination.PaginationManager; +import fr.maxlego08.menu.api.registry.Registries; +import fr.maxlego08.menu.api.registry.RegistryManager; +import fr.maxlego08.menu.api.registry.WritableRegistry; import fr.maxlego08.menu.api.utils.Message; import fr.maxlego08.menu.api.utils.MetaUpdater; import fr.maxlego08.menu.api.utils.Placeholders; +import net.kyori.adventure.key.Key; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.configuration.file.YamlConfiguration; @@ -26,6 +33,7 @@ import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.NotNull; import java.io.File; @@ -113,12 +121,33 @@ public interface InventoryManager extends Listener { */ Inventory loadInventory(Plugin plugin, File file) throws InventoryException; + /** + * Returns an inventory by its key, {@code plugin:file}. + * + *

Build the key with {@link ZMenuKeys#fileKey(String, String)}, or read a reference written + * by a user with {@link #findInventory(String)}.

+ * + * @param key the key of the inventory + * @return Optional of {@link Inventory}. + */ + Optional getInventory(@NotNull Key key); + + /** + * Returns the keys of every loaded inventory. + * + * @return the keys, {@code plugin:file} + */ + Set getInventoryKeys(); + /** * Returns an optional of {@link Inventory} based on its name. * * @param name Inventory name. * @return Optional of {@link Inventory}. + * @deprecated a file name and the key of its inventory can differ (case, spaces). Use + * {@link #getInventory(Key)}, or {@link #findInventory(String)} for a name written by a user. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional getInventory(String name); /** @@ -127,7 +156,9 @@ public interface InventoryManager extends Listener { * @param plugin The plugin where the inventory comes from. * @param name Inventory name. * @return Optional of {@link Inventory}. + * @deprecated use {@link #getInventory(Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional getInventory(Plugin plugin, String name); /** @@ -136,7 +167,9 @@ public interface InventoryManager extends Listener { * @param pluginName The plugin name where the inventory comes from. * @param name Inventory name. * @return Optional of {@link Inventory}. + * @deprecated use {@link #getInventory(Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional getInventory(String pluginName, String name); /** @@ -154,6 +187,15 @@ public interface InventoryManager extends Listener { */ Collection getInventories(Plugin plugin); + /** + * Finds an inventory from a reference written by a user, in a file or a command: + * {@code plugin:file} is looked up exactly, while a plain {@code file} is looked up in zMenu + * first, then in the other plugins. + * + * @param inventoryName the reference + * @return Optional of {@link Inventory}. + * @see ZMenuKeys#inventoryKey(String) + */ Optional findInventory(@NotNull String inventoryName); /** @@ -163,12 +205,22 @@ public interface InventoryManager extends Listener { */ void deleteInventory(Inventory inventory); + /** + * Deletes an inventory by its key. + * + * @param key the key of the inventory + * @return True if the inventory has been deleted. + */ + boolean deleteInventory(@NotNull Key key); + /** * Deletes an inventory based on its name. * * @param name Inventory name. * @return True if the inventory has been deleted. + * @deprecated use {@link #deleteInventory(Key)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) boolean deleteInventory(String name); /** @@ -178,6 +230,11 @@ public interface InventoryManager extends Listener { */ void deleteInventories(Plugin plugin); + /** + * @return the inventories as {@code plugin:file}, lowercased + * @deprecated use {@link #getInventoryKeys()}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Set getInventoryNames(); /** @@ -223,13 +280,21 @@ public interface InventoryManager extends Listener { * @param inventory The inventory to be opened. * @param page Inventory page. * @param inventories List of old inventories. + * @deprecated use {@link #openInventory(Player, Inventory, int, List)}. */ - void openInventory(Player player, Inventory inventory, int page, Inventory... inventories); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default void openInventory(Player player, Inventory inventory, int page, Inventory... inventories) { + this.openInventory(player, inventory, page, new ArrayList<>(Arrays.asList(inventories))); + } /** * Loads buttons. The {@link ButtonLoaderRegisterEvent} event will be * called, allowing you to add your own buttons using this event. + * + * @deprecated part of zMenu's startup, not meant to be called by other plugins. Register your + * buttons with {@link ButtonManager#register(fr.maxlego08.menu.api.loader.ButtonLoader)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void loadButtons(); /** @@ -242,7 +307,10 @@ public interface InventoryManager extends Listener { * * @param materialLoader New material loader. * @return True if registered. + * @deprecated register it in {@link Registries#MATERIAL_LOADER}: + * {@code registries.writable(Registries.MATERIAL_LOADER).register(plugin, loader)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) boolean registerMaterialLoader(MaterialLoader materialLoader); /** @@ -250,23 +318,47 @@ public interface InventoryManager extends Listener { * * @param key The key to identify the material loader. * @return Optional of {@link MaterialLoader}. + * @deprecated read {@link Registries#MATERIAL_LOADER} with {@link ZMenuKeys#parse(String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional getMaterialLoader(String key); /** * Returns a list of material loaders. * * @return List of {@link MaterialLoader}. + * @deprecated read {@link Registries#MATERIAL_LOADER}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Collection getMaterialLoader(); + /** + * Opens an inventory by its key. When it does not exist, the player's inventory is closed and + * they are told so. + * + * @param player Player who will open the inventory. + * @param key the key of the inventory + */ + void openInventory(Player player, @NotNull Key key); + + /** + * Opens an inventory by its key, on a page. + * + * @param player Player who will open the inventory. + * @param key the key of the inventory + * @param page The page number of the inventory to open. + */ + void openInventory(Player player, @NotNull Key key, int page); + /** * Opens an inventory based on its name and the plugin. * * @param player Player who will open the inventory. * @param plugin The plugin where the inventory comes from. * @param inventoryName Name of the inventory to be opened. + * @deprecated use {@link #openInventory(Player, Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void openInventory(Player player, Plugin plugin, String inventoryName); /** @@ -275,7 +367,9 @@ public interface InventoryManager extends Listener { * @param player Player who will open the inventory. * @param pluginName The plugin where the inventory comes from. * @param inventoryName Name of the inventory to be opened. + * @deprecated use {@link #openInventory(Player, Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void openInventory(Player player, String pluginName, String inventoryName); /** @@ -285,28 +379,33 @@ public interface InventoryManager extends Listener { * @param pluginName The plugin where the inventory comes from. * @param inventoryName Name of the inventory to be opened. * @param page The page number of the inventory to open. + * @deprecated use {@link #openInventory(Player, Key, int)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void openInventory(Player player, String pluginName, String inventoryName, int page); /** * Opens an inventory based on its name. Note that - * this method searches in all inventories; it is more appropriate to use - * the method {@link #openInventory(Player, Plugin, String)}. + * this method searches in all inventories. * * @param player Player who will open the inventory. * @param inventoryName Name of the inventory to be opened. + * @deprecated use {@link #openInventory(Player, Key)}, or {@link #findInventory(String)} for a + * name written by a user. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void openInventory(Player player, String inventoryName); /** * Opens an inventory based on its name and a specific page number. - * This method allows a player to view a particular page of an inventory, - * facilitating navigation through multi-page inventories. * * @param player Player who will open the inventory. * @param inventoryName Name of the inventory to be opened. * @param page The page number of the inventory to open. + * @deprecated use {@link #openInventory(Player, Key, int)}, or {@link #findInventory(String)} + * for a name written by a user. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void openInventory(Player player, String inventoryName, int page); /** @@ -314,7 +413,9 @@ public interface InventoryManager extends Listener { * * @param pluginName The name of the plugin to be found. * @return Optional of a plugin that can contain the plugin if it exists. + * @deprecated not related to inventories; use {@link org.bukkit.plugin.PluginManager#getPlugin(String)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional getPluginIgnoreCase(String pluginName); /** @@ -328,7 +429,9 @@ public interface InventoryManager extends Listener { * Sets item name and item meta using Spigot or Adventure API. * * @return MetaUpdater. + * @deprecated use {@link MenuPlugin#getMetaUpdater()}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) MetaUpdater getMeta(); /** @@ -365,7 +468,10 @@ public interface InventoryManager extends Listener { * Displays the list of available inventories. * * @param sender Command sender. + * @deprecated the {@code /zm list} command, not meant to be called by other plugins; read the + * inventories with {@link #getInventories()}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void sendInventories(CommandSender sender); /** @@ -375,7 +481,9 @@ public interface InventoryManager extends Listener { * @param fileName Inventory file name. * @param inventorySize Inventory size. * @param inventoryName Inventory name. + * @deprecated the {@code /zm create} command, not meant to be called by other plugins. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void createNewInventory(CommandSender sender, String fileName, int inventorySize, String inventoryName); /** @@ -407,7 +515,10 @@ public interface InventoryManager extends Listener { * @param itemStack The itemStack * @param name The item name * @param type + * @deprecated the {@code /zm save} command, not meant to be called by other plugins; use + * {@link ItemStackService#fromItemStack(ItemStack)} to turn an item into a menu item. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void saveItem(CommandSender sender, ItemStack itemStack, String name, String type); /** @@ -416,9 +527,13 @@ public interface InventoryManager extends Listener { * * @param itemStack The item to convert. * @return The menu item, and the components that forced the fallback. + * @deprecated use {@link ItemStackService#fromItemStack(ItemStack)}. */ @NotNull - MenuItemStackConversion toMenuItemStack(@NotNull ItemStack itemStack); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default MenuItemStackConversion toMenuItemStack(@NotNull ItemStack itemStack) { + return this.getPlugin().getItemStackService().fromItemStack(itemStack); + } /** * Transforms a string list into a click list type. @@ -426,7 +541,9 @@ public interface InventoryManager extends Listener { * * @param loadClicks - Click type as string * @return Loaded click type + * @deprecated an internal helper of the menu loaders, not related to inventories. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) List loadClicks(List loadClicks); /** @@ -434,8 +551,13 @@ public interface InventoryManager extends Listener { * This method allows for adding a new ItemStack verification strategy to the system. * * @param itemStackSimilar The ItemStackSimilar instance to be registered. + * @deprecated register it in {@link Registries#ITEM_VERIFICATION}: + * {@code registries.writable(Registries.ITEM_VERIFICATION).register(plugin, comparison)}. */ - void registerItemStackVerification(ItemStackSimilar itemStackSimilar); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default void registerItemStackVerification(ItemStackSimilar itemStackSimilar) { + this.getRegistries().writable(Registries.ITEM_VERIFICATION).register(JavaPlugin.getProvidingPlugin(itemStackSimilar.getClass()), itemStackSimilar); + } /** * Retrieves an Optional ItemStackSimilar instance based on its name. @@ -444,8 +566,12 @@ public interface InventoryManager extends Listener { * * @param name The name of the ItemStack verification strategy to retrieve. * @return An Optional containing the ItemStackSimilar instance if found, or an empty Optional otherwise. + * @deprecated use {@link ItemStackService#getVerification(String)}. */ - Optional getItemStackVerification(String name); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default Optional getItemStackVerification(String name) { + return this.getPlugin().getItemStackService().getVerification(name); + } /** * Returns a collection of all registered ItemStackSimilar instances. @@ -453,14 +579,20 @@ public interface InventoryManager extends Listener { * The collection contains instances of ItemStackSimilar, each representing a different verification strategy. * * @return A Collection of ItemStackSimilar instances representing all registered verification strategies. + * @deprecated read {@link Registries#ITEM_VERIFICATION}. */ - Collection getItemStackVerifications(); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default Collection getItemStackVerifications() { + return this.getRegistries().registry(Registries.ITEM_VERIFICATION).values(); + } /** * Gets the scheduler associated with this button option. * * @return The ZScheduler associated with this button option. + * @deprecated use {@link MenuPlugin#getScheduler()}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) PlatformScheduler getScheduler(); /** @@ -468,29 +600,39 @@ public interface InventoryManager extends Listener { * * @param plugin The plugin with which the button option is to be registered. * @param buttonOption The class of the button option to register. + * @deprecated register it in {@link Registries#BUTTON_OPTION}: + * {@code registries.writable(Registries.BUTTON_OPTION).register(plugin, ButtonOptionType.of(plugin, MyOption.class))}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void registerOption(Plugin plugin, Class buttonOption); /** * Unregisters all button options associated with the given plugin. * * @param plugin The plugin whose button options are to be unregistered. + * @deprecated a plugin's options are removed when it disables; to remove them earlier use + * {@code registries.writable(Registries.BUTTON_OPTION).unregisterAll(plugin)}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void unregisterOptions(Plugin plugin); /** * Retrieves a map of all registered button options grouped by plugin. * - * @return A Map where each key is a Plugin and each value is a List of button option classes associated with that plugin. + * @return A copy of the registered options, grouped by plugin. + * @deprecated read {@link Registries#BUTTON_OPTION}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Map>> getOptions(); /** * Retrieves an Optional containing the class of the button option with the given name. * - * @param name The name of the button option to retrieve. + * @param name The fully qualified name of the class of the button option. * @return An Optional containing the button option class if found, or an empty Optional if not found. + * @deprecated read {@link Registries#BUTTON_OPTION}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional> getOption(String name); /** @@ -499,24 +641,36 @@ public interface InventoryManager extends Listener { * @param player The {@link OfflinePlayer} instance representing the player whose page number is being set. This object allows for the operation to affect players who are not currently online as well. * @param page The current page number to set for the player. This should be within the range of 1 to {@code maxPage}, inclusive. * @param maxPage The maximum number of pages available. This is used to validate the {@code page} parameter and ensure it does not exceed the available range of pages. + * @deprecated use {@link PaginationManager#setMenuPage(UUID, int, int)}. */ - void setPlayerPage(OfflinePlayer player, int page, int maxPage); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default void setPlayerPage(OfflinePlayer player, int page, int maxPage) { + this.getPaginationManager().setMenuPage(player.getUniqueId(), page, maxPage); + } /** * Retrieves the current page number of a given player in a paginated context. This method is typically used in conjunction with {@link #setPlayerPage(OfflinePlayer, int, int)} to manage navigation through a multi-page UI component, allowing for a dynamic user experience based on the player's interaction. * * @param player The {@link OfflinePlayer} instance representing the player whose current page number is being queried. This enables the method to obtain information about players who are not currently online. * @return The current page number of the specified player. If the player's page has not been set previously, this method may return a default value or 0, depending on implementation. + * @deprecated use {@link PaginationManager#getMenuPage(UUID)}. */ - int getPage(OfflinePlayer player); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default int getPage(OfflinePlayer player) { + return this.getPaginationManager().getMenuPage(player.getUniqueId()); + } /** * Obtains the maximum page number set for a given player in a paginated context. This method allows for the retrieval of the upper limit of the pagination range for a player, facilitating UI boundary checks and preventing navigation beyond the available content. * * @param player The {@link OfflinePlayer} instance representing the player whose maximum page number is being retrieved. As with other methods, this supports offline players, allowing for a wide range of applications. * @return The maximum page number available for the specified player. Similar to {@link #getPage(OfflinePlayer)}, if the max page has not been set, this method might return a default value or 0, depending on how it's implemented. + * @deprecated use {@link PaginationManager#getMenuMaxPage(UUID)}. */ - int getMaxPage(OfflinePlayer player); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default int getMaxPage(OfflinePlayer player) { + return this.getPaginationManager().getMenuMaxPage(player.getUniqueId()); + } /** * Retrieves a fake inventory for testing or simulation purposes. @@ -529,14 +683,18 @@ public interface InventoryManager extends Listener { * Provides access to the enchantments management interface. * * @return An instance of {@link Enchantments} for managing custom enchantments. + * @deprecated use {@link MenuPlugin#getEnchantments()}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Enchantments getEnchantments(); /** * Retrieves the font image utility for managing custom fonts. * * @return An instance of {@link FontImage} for handling font images. + * @deprecated use {@link MenuPlugin#getFontImage()}. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) FontImage getFontImage(); /** @@ -544,7 +702,9 @@ public interface InventoryManager extends Listener { * * @param file The YAML file to load. * @return The loaded YAML configuration. + * @deprecated an internal helper of the menu loaders, not related to inventories. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) YamlConfiguration loadYamlConfiguration(File file); /** @@ -556,15 +716,44 @@ public interface InventoryManager extends Listener { * the specific aspect of the inventory being targeted. * @param value The value associated with the inventory requirement, used to * configure or modify the inventory setting as per the type specified. + * @deprecated part of zMenu's loading of menus that wait for a button, an action or a + * pattern, not meant to be called by other plugins. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void loadElement(InventoryRequirementType type, String value); + /** + * Registers an option added to every inventory. + * + * @param plugin the plugin the option belongs to + * @param inventoryOption the class of the option + * @deprecated register it in {@link Registries#INVENTORY_OPTION}: + * {@code registries.writable(Registries.INVENTORY_OPTION).register(plugin, InventoryOptionType.of(plugin, MyOption.class))}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void registerInventoryOption(Plugin plugin, Class inventoryOption); + /** + * @return a copy of the registered inventory options, grouped by plugin + * @deprecated read {@link Registries#INVENTORY_OPTION}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Map>> getInventoryOptions(); + /** + * @param name the fully qualified name of the class of the option + * @return the class of the option, if one is registered with this name + * @deprecated read {@link Registries#INVENTORY_OPTION}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) Optional> getInventoryOption(String name); + /** + * @param plugin the plugin whose options are removed + * @deprecated a plugin's options are removed when it disables; to remove them earlier use + * {@code registries.writable(Registries.INVENTORY_OPTION).unregisterAll(plugin)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void unregisterInventoryOptions(Plugin plugin); void registerInventoryListener(InventoryListener inventoryListener); @@ -573,14 +762,60 @@ public interface InventoryManager extends Listener { List getInventoryListeners(); - ItemStack postProcessSkullItemStack(ItemStack itemStack, Button button, Player player); + /** + * Gives a player head the skin of the player named by the {@code player-head} of a button. + * + * @param itemStack the head + * @param button the button + * @param player the player + * @return the head + * @deprecated use {@link ItemStackService#applyPlayerHead(ItemStack, Button, Player, Placeholders)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) + default ItemStack postProcessSkullItemStack(ItemStack itemStack, Button button, Player player) { + return this.getPlugin().getItemStackService().applyPlayerHead(itemStack, button, player, new Placeholders()); + } - ItemStack postProcessSkullItemStack(ItemStack itemStack, Button button, Player player, Placeholders placeholders); + /** + * Gives a player head the skin of the player named by the {@code player-head} of a button. + * + * @param itemStack the head + * @param button the button + * @param player the player + * @param placeholders the placeholders of the inventory + * @return the head + * @deprecated use {@link ItemStackService#applyPlayerHead(ItemStack, Button, Player, Placeholders)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) + default ItemStack postProcessSkullItemStack(ItemStack itemStack, Button button, Player player, Placeholders placeholders) { + return this.getPlugin().getItemStackService().applyPlayerHead(itemStack, button, player, placeholders); + } + /** + * Sends one of zMenu's messages. + * + * @param sender the receiver + * @param message the message + * @param args the placeholders, as pairs of key and value + * @deprecated zMenu's messages are not meant to be sent by other plugins. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void sendMessage(CommandSender sender, Message message, Object... args); + /** + * Loads the buttons, patterns, animations and inventories. + * + * @deprecated part of zMenu's startup, not meant to be called by other plugins; use + * {@link #loadInventories()} to read the menus again. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void load(); + /** + * Gets the plugin this manager belongs to. + * + * @return the plugin + */ MenuPlugin getPlugin(); /** @@ -592,8 +827,12 @@ public interface InventoryManager extends Listener { * @param path the path within the configuration to load from. * @param file the file from which the configuration was loaded, used for logging errors. * @return the loaded menu item stack. + * @deprecated use {@link ItemStackService#load(YamlConfiguration, String, File)}. */ - MenuItemStack loadItemStack(YamlConfiguration configuration, String path, File file); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default MenuItemStack loadItemStack(YamlConfiguration configuration, String path, File file) { + return this.getPlugin().getItemStackService().load(configuration, path, file); + } /** * Loads a menu item stack from a file, using the specified path and additional parameters. @@ -608,10 +847,25 @@ public interface InventoryManager extends Listener { * @param map A map of additional parameters or overrides for customizing the item stack. * @return The loaded MenuItemStack object. *

+ * @deprecated the path is not used; use {@link ItemStackService#load(File, Map)}. */ - MenuItemStack loadItemStack(File file, String path, Map map); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default MenuItemStack loadItemStack(File file, String path, Map map) { + return this.getPlugin().getItemStackService().load(file, map); + } - MenuItemStack loadItemStack(File file, Map map); + /** + * Loads a menu item stack from a map of its settings. + * + * @param file the file the map was read from + * @param map the settings of the item + * @return the menu item stack + * @deprecated use {@link ItemStackService#load(File, Map)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) + default MenuItemStack loadItemStack(File file, Map map) { + return this.getPlugin().getItemStackService().load(file, map); + } /** * Provides access to the pagination manager for handling paginated content in inventories. @@ -636,4 +890,79 @@ public interface InventoryManager extends Listener { */ PaginationManager getPaginationManager(); + /** + * Gets the manager holding the named animations: the files of the {@code animations} folder and + * the ones registered by plugins. + * + *

Use it to fetch an animation, or to register one of your own:

+ * + *
+     * inventoryManager.getAnimationManager().registerAnimation(plugin, new Animation(Key.key("myplugin", "my_animation"), settings));
+     * 
+ * + * @return The animation manager. + * @see AnimationManager + */ + AnimationManager getAnimationManager(); + + /** + * Gets every registry of zMenu, such as the animations an inventory may use as its {@code type}. + * + *

Read an entry, and register your own from your {@code onEnable}:

+ * + *
+     * inventoryManager.getRegistries().registry(Registries.SLOT_ANIMATION).get(ZMenuKeys.key("spiral"));
+     * inventoryManager.getRegistries().writable(Registries.MY_REGISTRY).register(plugin, myEntry);
+     * 
+ * + *

Static registries stop accepting entries once the server has finished loading.

+ * + * @return The registries. + * @see fr.maxlego08.menu.api.registry.Registries + */ + RegistryManager getRegistries(); + + /** + * Registers an animation menus may use as their {@code type}, under its key and its aliases. + * Call it from your {@code onEnable}: + * + *
+     * inventoryManager.registerSlotAnimation(plugin, new MyAnimationLoader());
+     * 
+ * + * @param plugin the plugin registering it, shown when two animations share a name + * @param loader the loader + * @throws fr.maxlego08.menu.api.registry.RegistrationException if a name is taken, or the server has finished loading + */ + default void registerSlotAnimation(@NotNull Plugin plugin, @NotNull SlotAnimationLoader loader) { + WritableRegistry registry = this.getRegistries().writable(Registries.SLOT_ANIMATION); + registry.register(plugin, loader); + for (Key alias : loader.aliases()) { + registry.alias(plugin.getName(), alias, loader.key()); + } + } + + /** + * Writes an item as a base64 string. + * + * @param itemStack the item + * @return the item as base64 + * @deprecated use {@link ItemStackService#serialize(ItemStack)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) + default String serializeItemStack(ItemStack itemStack) { + return this.getPlugin().getItemStackService().serialize(itemStack); + } + + /** + * Reads an item written as a base64 string. + * + * @param serializedItemStack the item as base64 + * @return the item + * @deprecated use {@link ItemStackService#deserialize(String)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) + default ItemStack deserializeItemStack(String serializedItemStack) { + return this.getPlugin().getItemStackService().deserialize(serializedItemStack); + } } \ No newline at end of file diff --git a/API/src/main/java/fr/maxlego08/menu/api/InventoryOptionType.java b/API/src/main/java/fr/maxlego08/menu/api/InventoryOptionType.java new file mode 100644 index 000000000..eedf747ad --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/InventoryOptionType.java @@ -0,0 +1,38 @@ +package fr.maxlego08.menu.api; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * An {@link InventoryOption} registered in {@link fr.maxlego08.menu.api.registry.Registries#INVENTORY_OPTION}. + * Every inventory gets a new instance of every registered option. + * + * @param key the key of the option, {@code :} for {@link #of(Plugin, Class)} + * @param plugin the plugin the option belongs to, passed to its constructor + * @param optionClass the class of the option, created with a {@code (Plugin)} or a no-argument constructor + */ +public record InventoryOptionType(@NotNull Key key, @NotNull Plugin plugin, @NotNull Class optionClass) implements Keyed { + + /** + * Creates the type of an option, keyed by its plugin and the simple name of its class. + * + * @param plugin the plugin the option belongs to + * @param optionClass the class of the option + * @return the type + */ + public static @NotNull InventoryOptionType of(@NotNull Plugin plugin, @NotNull Class optionClass) { + return new InventoryOptionType(ZMenuKeys.fileKey(plugin.getName(), optionClass.getSimpleName()), plugin, optionClass); + } + + /** + * Creates a new instance of the option. + * + * @return the option, or null if it cannot be created + */ + public @Nullable InventoryOption create() { + return InventoryOption.create(this.plugin, this.optionClass); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/ItemManager.java b/API/src/main/java/fr/maxlego08/menu/api/ItemManager.java index 93fea21ae..f5ab3676f 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/ItemManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/ItemManager.java @@ -32,8 +32,22 @@ public interface ItemManager { void unloadListeners(); + /** + * Registers a mechanic that custom items can use. + * + * @param factory the factory of the mechanic + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#MECHANIC}: + * {@code registries.writable(Registries.MECHANIC).register(plugin, factory)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void registerMechanicFactory(MechanicFactory factory); + /** + * Finds a mechanic by the id written in a custom item file. + * + * @param mechanicId the id of the mechanic + * @return the factory, if one has this id + */ Optional> getMechanicFactory(String mechanicId); void giveItem(Player player, String itemId); diff --git a/API/src/main/java/fr/maxlego08/menu/api/MenuItemStack.java b/API/src/main/java/fr/maxlego08/menu/api/MenuItemStack.java index 57dff0cd0..a5c410492 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/MenuItemStack.java +++ b/API/src/main/java/fr/maxlego08/menu/api/MenuItemStack.java @@ -249,14 +249,18 @@ public interface MenuItemStack extends MenuItemStackContext, SectionSerializable * Marks whether PlaceholderAPI is required for this item stack. * * @param needPlaceholderAPI {@code true} when PlaceholderAPI is needed. + * @deprecated set by zMenu from the item's text when it is loaded; changing it breaks the item cache. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void setNeedPlaceholderAPI(boolean needPlaceholderAPI); /** * Sets the cached item stack value used when caching is enabled. * * @param cacheItemStack the ItemStack to cache. + * @deprecated filled by zMenu when the item is built; setting it from outside serves a stale item. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void setCacheItemStack(ItemStack cacheItemStack); /** diff --git a/API/src/main/java/fr/maxlego08/menu/api/MenuPlugin.java b/API/src/main/java/fr/maxlego08/menu/api/MenuPlugin.java index 949e90bae..0ee98f0d1 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/MenuPlugin.java +++ b/API/src/main/java/fr/maxlego08/menu/api/MenuPlugin.java @@ -3,13 +3,16 @@ import com.tcoded.folialib.impl.PlatformScheduler; import fr.maxlego08.menu.api.attribute.AttributApplier; import fr.maxlego08.menu.api.command.CommandManager; +import fr.maxlego08.menu.api.configuration.Feature; import fr.maxlego08.menu.api.dupe.DupeManager; import fr.maxlego08.menu.api.enchantment.Enchantments; +import fr.maxlego08.menu.api.itemstack.ItemStackService; import fr.maxlego08.menu.api.font.FontImage; import fr.maxlego08.menu.api.interfaces.ReturnBiConsumer; import fr.maxlego08.menu.api.pattern.PatternManager; import fr.maxlego08.menu.api.players.DataManager; import fr.maxlego08.menu.api.players.inventory.InventoriesPlayer; +import fr.maxlego08.menu.api.sound.SoundService; import fr.maxlego08.menu.api.storage.StorageManager; import fr.maxlego08.menu.api.utils.MetaUpdater; import fr.maxlego08.menu.api.utils.toast.ToastHelper; @@ -18,8 +21,10 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; import java.io.File; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; @@ -213,19 +218,25 @@ public interface MenuPlugin extends Plugin { /** * Checks if the plugin is a Paper plugin. * @return true if the plugin is a Paper plugin, false otherwise + * @deprecated zMenu only runs on Paper and its forks, so this is always true. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) boolean isPaper(); /** * Checks if the plugin is a Paper or Folia plugin. * @return true if the plugin is a Paper or Folia plugin, false otherwise + * @deprecated zMenu only runs on Paper and its forks, so this is always true. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) boolean isPaperOrFolia(); /** * Checks if the plugin is a Spigot plugin. * @return true if the plugin is a Spigot plugin, false otherwise + * @deprecated zMenu only runs on Paper and its forks, so this is always false. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) boolean isSpigot(); /** @@ -249,8 +260,36 @@ public interface MenuPlugin extends Plugin { * @param path the path within the configuration to load from. * @param file the file from which the configuration was loaded, used for logging errors. * @return the loaded menu item stack. + * @deprecated use {@link ItemStackService#load(YamlConfiguration, String, File)}. */ - MenuItemStack loadItemStack(YamlConfiguration configuration, String path, File file); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default MenuItemStack loadItemStack(YamlConfiguration configuration, String path, File file) { + return this.getItemStackService().load(configuration, path, file); + } + + /** + * Gets the service that reads, converts and compares the items of zMenu. + * + * @return the item stack service + */ + ItemStackService getItemStackService(); + + /** + * Gets the service that creates the sounds of zMenu and reads them from files. + * + * @return the sound service + */ + SoundService getSoundService(); + + /** + * Reads a YAML file through zMenu's file cache: a file read by several menus is parsed once, and + * read again when it changes on disk. + * + * @param path the file + * @return the configuration, or empty if the file does not exist + */ + @NotNull + Optional readYamlFile(@NotNull Path path); /** * Returns the ToastHelper object for this plugin. @@ -273,8 +312,23 @@ default ClientVersionManager getClientVersionManager() { return ClientVersionManager.SERVER_VERSION_ONLY; } + /** + * Returns the custom item manager. Never null: when {@link Feature#CUSTOM_ITEMS} is disabled it is + * a manager that knows no item and ignores every registration. + * + * @return the item manager + */ ItemManager getItemManager(); + /** + * Tells whether a feature is running. Features are read from {@code config.yml} once when zMenu + * enables, a change made afterwards only applies after a restart. + * + * @param feature the feature + * @return whether the feature is enabled + */ + boolean isFeatureEnabled(@NotNull Feature feature); + AttributApplier getAttributApplier(); TitleAnimationManager getTitleAnimationManager(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java b/API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java new file mode 100644 index 000000000..da50547f8 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java @@ -0,0 +1,117 @@ +package fr.maxlego08.menu.api; + +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +/** + * A class that provides utility methods for creating namespaced keys with zmenu as the namespace. + * This class cannot be instantiated. + */ +public final class ZMenuKeys { + public static final String PLUGIN_NAME = "zMenu"; + public static final String NAMESPACE = "zmenu"; + + /** + * The constructor is private to prevent instantiation. + */ + private ZMenuKeys() { + } + + /** + * Creates a namespaced key with the namespace {@link #NAMESPACE} and the given value. + * + * @param value the value of the key + * @return a namespaced key with the namespace {@link #NAMESPACE} and the given value + */ + public static @NotNull Key key(@NotNull String value) { + return Key.key(NAMESPACE, value); + } + + /** + * Gets the key of something read from a file, such as an inventory. Unlike + * {@link #parse(String)}, "-" stays "-", so {@code my-shop} and {@code my_shop} stay apart. + * + * @param namespace usually the name of the plugin that owns the file + * @param fileName the name of the file, without its extension + * @return the key + */ + public static @NotNull Key fileKey(@NotNull String namespace, @NotNull String fileName) { + return Key.key(sanitize(namespace, false), sanitize(fileName, false)); + } + + /** + * Reads a reference to an inventory written in a file or a command: {@code plugin:file} or + * {@code file}, which belongs to {@link #NAMESPACE}. + * + * @param reference the reference + * @return the key of the inventory + */ + public static @NotNull Key inventoryKey(@NotNull String reference) { + int separator = reference.indexOf(':'); + return separator < 0 ? fileKey(NAMESPACE, reference) : fileKey(reference.substring(0, separator), reference.substring(separator + 1)); + } + + /** + * Tells whether a reference names this key. {@code plugin:file} must match exactly, while a + * plain {@code file} matches that file name in any namespace, as name lookups always did. + * + * @param key the key of an inventory + * @param reference the reference + * @return whether they match + */ + public static boolean matches(@NotNull Key key, @NotNull String reference) { + Key referenced = inventoryKey(reference); + return reference.indexOf(':') < 0 ? key.value().equals(referenced.value()) : key.equals(referenced); + } + + /** + * Lowercases a name and replaces every character a key cannot hold with "_", so that a file + * named {@code My Shop} is {@code my_shop}. A value may also hold "/", a namespace may not. + */ + private static String sanitize(String name, boolean value) { + String lower = name.trim().toLowerCase(Locale.ROOT); + StringBuilder builder = new StringBuilder(lower.length()); + for (int index = 0; index < lower.length(); index++) { + char character = lower.charAt(index); + boolean valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '_' || character == '-' || character == '.' + || (value && character == '/'); + builder.append(valid ? character : '_'); + } + return builder.isEmpty() ? "_" : builder.toString(); + } + + /** + * Reads a key written in a file or a command. The case is ignored, and "-" and spaces are read + * as "_": {@code Play Animation}, {@code play-animation} and {@code play_animation} are the same + * key. Without a namespace, the key belongs to {@link #NAMESPACE}: {@code spiral} is + * {@code zmenu:spiral}, while {@code myplugin:spiral} keeps its own namespace. + * + *

Any name gives a key, so the names plugins used before the registries keep working: every + * other character a key cannot hold, such as {@code +}, {@code !}, an accent or a second + * {@code :}, is read as "_" too, so {@code Shop+Buy} is {@code zmenu:shop_buy}. The same name + * always gives the same key, both when it is registered and when a file names it.

+ * + * @param input the text to read + * @return the key + */ + public static @NotNull Key parse(@NotNull String input) { + String text = input.trim(); + int separator = text.indexOf(':'); + String namespace = separator < 0 ? NAMESPACE : sanitize(text.substring(0, separator), false); + String value = sanitize(text.substring(separator + 1).replace('-', '_'), true); + return Key.key(namespace, value); + } + + /** + * Reads a key like {@link #parse(String)}, or null when there is no text to read. + * + * @param input the text to read, may be null + * @return the key, or null if the text is null or blank + */ + public static @Nullable Key tryParse(@Nullable String input) { + return input == null || input.isBlank() ? null : parse(input); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java new file mode 100644 index 000000000..765c9fe3b --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java @@ -0,0 +1,31 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +/** + * An animation known by a key: a file of the {@code animations} folder ({@code zmenu:}), + * one registered by a plugin, or the animation of an inventory (the key of the inventory). + * + *

The {@code animation} action plays a named one with {@code file: pulse}, or + * {@code file: myplugin:glow} for one registered by a plugin. The {@code open-animation} and + * {@code close-animation} of an inventory only read files of the animations folder.

+ * + * @param key the key the animation is known by + * @param settings what the animation does + */ +public record Animation(@NotNull Key key, @NotNull SlotAnimationSettings settings) implements Keyed { + + /** + * The key as text, {@code namespace:value}, used in messages and to derive the random seed of + * the animation for a player. + * + * @return the name of the animation + */ + @NotNull + public String name() { + return this.key.asString(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java new file mode 100644 index 000000000..a0bf49c2b --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java @@ -0,0 +1,135 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationVariant; +import fr.maxlego08.menu.api.animation.slot.settings.item.ItemChoice; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * What a {@link SlotAnimation} is being asked to order, and who for. + * + *

The player and the inventory are known when an animation is compiled but not when it is + * written, so an ordering may depend on them: a different order per player, a seed that stays the + * same for one player, or an order that reads something from the menu itself.

+ * + *

They are null when an animation is compiled outside of an inventory, such as in a test, so an + * implementation using them must cope with their absence.

+ * + * @param inventorySize the number of slots the animation is laid out over: the menu and the player + * inventory together, not the size of the menu alone. See {@link AnimationSlots} + * @param slots the slots to order + * @param player the player the animation is played for, if there is one + * @param inventory the menu being animated, if there is one + * @param random the source every roll of the animation is taken from + */ +public record AnimationContext( + int inventorySize, + @NotNull List slots, + @Nullable Player player, + @Nullable Inventory inventory, + @NotNull Random random +) { + + public AnimationContext { + slots = List.copyOf(slots); + } + + public AnimationContext(int inventorySize, @NotNull List slots, @Nullable Player player, @Nullable Inventory inventory) { + this(inventorySize, slots, player, inventory, ThreadLocalRandom.current()); + } + + /** + * Rolls a chance. + * + *

Everything an animation leaves to chance goes through here, so a test giving the context a + * seeded {@link Random} gets the same animation every time.

+ * + * @param chance the chance in percent, 100 always passing and 0 never + * @return true if the roll passed + */ + public boolean roll(float chance) { + if (chance >= 100) return true; + if (chance <= 0) return false; + + return this.random.nextDouble() * 100 < chance; + } + + /** + * Picks a number of entries at random, without repeating any of them. + * + * @param values the entries to pick from + * @param count how many to pick; anything from the size of the list upwards returns them all + * @param the type of the entries + * @return the entries picked, in the order they were given + */ + @NotNull + public List pickSome(@NotNull List values, int count) { + if (count <= 0 || count >= values.size()) return List.copyOf(values); + + List shuffled = new ArrayList<>(values); + Collections.shuffle(shuffled, this.random); + + List picked = new ArrayList<>(shuffled.subList(0, count)); + picked.sort(Comparator.comparingInt(values::indexOf)); + return picked; + } + + /** + * Picks one of the variants of an animation, respecting their weights. + * + * @param variants the variants to pick from + * @return the variant picked, or null if there is nothing to pick from + */ + @Nullable + public AnimationVariant pickVariant(@NotNull List variants) { + if (variants.isEmpty()) return null; + if (variants.size() == 1) return variants.getFirst(); + + int total = 0; + for (AnimationVariant variant : variants) { + total += variant.weight(); + } + + int roll = this.random.nextInt(total); + for (AnimationVariant variant : variants) { + roll -= variant.weight(); + if (roll < 0) return variant; + } + return variants.getLast(); + } + + @Nullable + public MenuItemStack pick(@NotNull List choices) { + if (choices.isEmpty()) return null; + if (choices.size() == 1) return choices.getFirst().item(); + + int total = 0; + for (ItemChoice choice : choices) { + total += choice.weight(); + } + + int roll = this.random.nextInt(total); + for (ItemChoice choice : choices) { + roll -= choice.weight(); + if (roll < 0) return choice.item(); + } + return choices.getLast().item(); + } + + /** + * The same context, for another set of slots. + * + * @param slots the slots to order + * @return the new context + */ + @NotNull + public AnimationContext withSlots(@NotNull List slots) { + return new AnimationContext(this.inventorySize, slots, this.player, this.inventory, this.random); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationHistory.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationHistory.java new file mode 100644 index 000000000..ad4bc9e4f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationHistory.java @@ -0,0 +1,95 @@ +package fr.maxlego08.menu.api.animation.slot; + +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +/** + * Remembers which opening animations a player has already seen, so that an animation played + * {@link AnimationPlayMode#ONCE once} or with a cooldown is not played again too early. + * + *

Two memories are kept: one for the session, forgotten when the player leaves, and one stored in + * the database for the animations that remember themselves beyond the session.

+ * + * @see AnimationManager#getHistory() + */ +public interface AnimationHistory { + + /** + * Reads back which animations a player has already seen, when they join. + * + *

Called once per connection. The rows are fetched off the main thread and kept in memory, + * so that opening a menu never waits on the database.

+ * + * @param player The player. + */ + void load(@NotNull Player player); + + /** + * Whether what a player has already seen is known yet. + * + *

False for the short moment between joining and the rows coming back, during which an + * animation that only plays once is held back rather than risking spending its single play.

+ * + * @param player The player. + * @return true once the player's history is in memory. + */ + boolean isLoaded(@NotNull Player player); + + /** + * Forgets everything kept in memory for a player, when they disconnect. The database is not touched. + * + * @param uniqueId The unique id of the player. + */ + void unload(@NotNull UUID uniqueId); + + /** + * Returns whether an animation has already been played for a player since they connected. + * + * @param player The player. + * @param animationKey The key identifying the animation. + * @return true if the animation has already been played. + */ + boolean hasPlayedThisSession(@NotNull Player player, @NotNull String animationKey); + + /** + * Remembers, until the player disconnects, that an animation has been played for them. + * + * @param player The player. + * @param animationKey The key identifying the animation. + */ + void markPlayedThisSession(@NotNull Player player, @NotNull String animationKey); + + /** + * When a player last saw an animation that remembers itself beyond the session. + * + *

Answered from memory: the rows are read once when the player joins, because a menu opens + * far too often to ask the database each time.

+ * + * @param player The player. + * @param animationKey The key identifying the animation. + * @return the time it last played in milliseconds since the epoch, or null if it never has. + */ + @Nullable + Long getPlayedAt(@NotNull Player player, @NotNull String animationKey); + + /** + * Remembers that an animation has played, in the database as well as in memory. + * + * @param player The player. + * @param animationKey The key identifying the animation. + * @param playedAt When it played, in milliseconds since the epoch. + */ + void markPlayed(@NotNull Player player, @NotNull String animationKey, long playedAt); + + /** + * Forgets that a player has seen an animation, in the session and in the database, so one that + * only plays once plays again. + * + * @param uniqueId The unique id of the player. + * @param animationKey The animation to forget, or null to forget all of them. + */ + void forget(@NotNull UUID uniqueId, @Nullable String animationKey); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java new file mode 100644 index 000000000..22f8ed451 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java @@ -0,0 +1,126 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.registry.Registries; +import fr.maxlego08.menu.api.registry.reload.ReloadReport; +import net.kyori.adventure.key.Key; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Optional; + +/** + * Holds the named animations, so that an inventory, an action or another plugin can use one by key. + * + *

They live in the reloadable {@link Registries#ANIMATION} registry: the files of the + * {@code animations} folder are known as {@code zmenu:}, and the animations registered by a + * plugin are provided again on every reload, so a plugin registers its own once, at startup.

+ */ +public interface AnimationManager { + + /** + * Gets an animation by its key. + * + * @param key the key of the animation + * @return the animation, if it exists + */ + @NotNull + Optional getAnimation(@NotNull Key key); + + /** + * Finds an animation from a name written in a file: {@code pulse} is the file + * {@code animations/pulse.yml}, {@code myplugin:glow} an animation registered by a plugin. + * + * @param name the name of the animation + * @return the animation, if it exists + */ + @NotNull + Optional findAnimation(@NotNull String name); + + /** + * Gets every animation currently known. + * + * @return the animations + */ + @NotNull + Collection getAnimations(); + + /** + * Registers an animation, owned by a plugin. Give it a key in the namespace of the plugin, such as + * {@code myplugin:glow}: the {@code zmenu} namespace holds the files of the animations folder. + * + *

The animation survives a reload of the folder, so a plugin only has to register it once.

+ * + * @param plugin the plugin registering it + * @param animation the animation to register + * @throws fr.maxlego08.menu.api.registry.RegistrationException if an animation already has this key + */ + void registerAnimation(@NotNull Plugin plugin, @NotNull Animation animation); + + /** + * Registers an animation written in code. + * + * @param plugin the plugin registering it + * @param animation the animation to register + * @see CustomAnimation + */ + default void registerAnimation(@NotNull Plugin plugin, @NotNull CustomAnimation animation) { + this.registerAnimation(plugin, animation.toAnimation()); + } + + /** + * Removes an animation registered by a plugin. + * + * @param key the key of the animation + * @return whether an animation registered by a plugin had this key + */ + boolean unregisterAnimation(@NotNull Key key); + + /** + * Reloads the animations: the files of the {@code animations} folder are read again, and the + * animations registered by plugins are kept. A file that cannot be read keeps its previous version. + * + * @return what the reload found + */ + @NotNull + ReloadReport loadAnimations(); + + /** + * Plays a named animation on the inventory a player is looking at. + * + * @param player the player + * @param key the key of the animation + * @param playback how to play it + * @return how long the animation lasts, in milliseconds, 0 if the player is not looking at a + * zMenu inventory, the animation does not exist, or it had nothing to play + */ + long play(@NotNull Player player, @NotNull Key key, @NotNull AnimationPlayback playback); + + /** + * Plays an animation on the inventory a player is looking at. + * + * @param player the player + * @param animation the animation to play + * @param playback how to play it + * @return how long the animation lasts, in milliseconds, 0 if the player is not looking at a + * zMenu inventory or the animation had nothing to play + */ + long play(@NotNull Player player, @NotNull Animation animation, @NotNull AnimationPlayback playback); + + /** + * Finishes the animation playing on the inventory a player is looking at, applying everything + * that is left at once. + * + * @param player the player + */ + void stop(@NotNull Player player); + + /** + * Gets which opening animations each player has already seen. + * + * @return the history of played animations + */ + @NotNull + AnimationHistory getHistory(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java new file mode 100644 index 000000000..115c79d87 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java @@ -0,0 +1,94 @@ +package fr.maxlego08.menu.api.animation.slot; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +/** + * When the opening animation of an inventory is allowed to play. + * + *

Refreshing an inventory never plays the animation, whatever the mode: a page change or a + * {@code refresh inventory} action re-opens the same menu, and replaying the whole animation there + * would make a paginated menu unusable.

+ */ +public enum AnimationPlayMode { + + /** + * Plays every time the player opens the inventory. + */ + ALWAYS, + + /** + * Plays the first time the player opens this inventory, then never again until they reconnect. + */ + ONCE, + + /** + * Plays the first time the player opens this inventory and never again, remembered in the + * database so it survives a reconnect and a restart. + * + *

Falls back to {@link #ONCE} when the database is disabled, since there is then nowhere to + * remember it.

+ */ + ONCE_FOREVER; + + /** + * Whether this mode remembers itself beyond the session. + * + * @return true for {@link #ONCE_FOREVER} + */ + public boolean isPersistent() { + return this == ONCE_FOREVER; + } + + /** + * Whether this mode plays at most once. + * + * @return true for {@link #ONCE} and {@link #ONCE_FOREVER} + */ + public boolean isOnce() { + return this == ONCE || this == ONCE_FOREVER; + } + + /** + * Decides whether an animation may play, from when the player last saw it. + * + *

A cooldown and a once mode are independent: the cooldown alone turns {@link #ALWAYS} into + * "at most this often", and a once mode ignores it because it never gets a second chance + * anyway.

+ * + * @param lastPlayedAt when the player last saw it, in milliseconds since the epoch, or null if + * they never have + * @param now the current time, in milliseconds since the epoch + * @param cooldown the minimum time between two plays, in milliseconds; 0 for none + * @return true if the animation may play + */ + @Contract(pure = true) + public boolean canPlay(@Nullable Long lastPlayedAt, long now, long cooldown) { + if (lastPlayedAt == null) return true; + if (this.isOnce()) return false; + return cooldown <= 0 || now - lastPlayedAt >= cooldown; + } + + /** + * Parses a mode from a configuration value. + * + * @param value the value to parse, can be null + * @param defaultValue the value to return if the given value is null or invalid + * @return the parsed mode + */ + @Contract(pure = true) + @NotNull + public static AnimationPlayMode parse(@Nullable String value, @NotNull AnimationPlayMode defaultValue) { + if (value == null) return defaultValue; + String name = value.toUpperCase(Locale.ROOT).replace('-', '_').trim(); + if (name.equals("FIRST_OPEN")) return ONCE; + try { + return AnimationPlayMode.valueOf(name); + } catch (IllegalArgumentException exception) { + return defaultValue; + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java new file mode 100644 index 000000000..ae61e5e45 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java @@ -0,0 +1,62 @@ +package fr.maxlego08.menu.api.animation.slot; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +/** + * How an animation is played on an inventory. + */ +public enum AnimationPlayback { + + /** + * The buttons are hidden and revealed again. This is what opening an inventory does. + */ + REVEAL, + + /** + * Only the temporary items play. The buttons are never hidden and keep working, so the animation + * draws over a menu the player is already looking at. + * + *

Group boundaries still mean what they would have meant: {@code group:2} is the moment group + * 2 would have started, so an animation reads the same whichever way it is played.

+ */ + OVERLAY, + + /** + * The buttons are taken away one by one, in the order the animation gives. This is the reverse + * of {@link #REVEAL}, and is what a menu closing itself does. + * + *

Nothing is hidden up front: the menu is whole when it starts and empty when it ends.

+ */ + CONCEAL; + + /** + * Whether this playback hides the buttons before putting them back. + * + * @return true for {@link #REVEAL} only + */ + public boolean hidesButtons() { + return this == REVEAL; + } + + /** + * Parses a playback from a configuration value. + * + * @param value the value to parse, can be null + * @param defaultValue the value to return if the given value is null or invalid + * @return the parsed playback + */ + @Contract(pure = true) + @NotNull + public static AnimationPlayback parse(@Nullable String value, @NotNull AnimationPlayback defaultValue) { + if (value == null) return defaultValue; + try { + return AnimationPlayback.valueOf(value.toUpperCase(Locale.ROOT).replace('-', '_').trim()); + } catch (IllegalArgumentException exception) { + return defaultValue; + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java new file mode 100644 index 000000000..e7090da94 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java @@ -0,0 +1,82 @@ +package fr.maxlego08.menu.api.animation.slot; + +/** + * Maps the slots of the menu and the slots of the player inventory into the one space an animation + * is laid out over. + * + *

An animation should be able to sweep from the menu into the player inventory without a seam, so + * the two are compiled together: the slots of the menu come first, then the slots of the player + * inventory as they appear on screen.

+ * + *

That last part is the reason this class exists. Bukkit numbers the player inventory with the + * hotbar first, slots 0 to 8, and the three rows above it from 9 to 35, which is the opposite of the + * order they are displayed in. Animating over the raw numbers would make a spiral or a snake jump + * from the bottom row back to the top.

+ */ +public final class AnimationSlots { + + /** + * The number of player inventory slots taken part in an animation: the three rows of storage and + * the hotbar. + */ + public static final int PLAYER_SIZE = 36; + + /** + * The first player inventory slot as Bukkit numbers them, above the hotbar. + */ + private static final int FIRST_STORAGE_SLOT = 9; + + private AnimationSlots() { + } + + /** + * The total number of slots an animation is laid out over. + * + * @param inventorySize the size of the menu + * @return the size of the menu plus the player inventory + */ + public static int totalSize(int inventorySize) { + return inventorySize + PLAYER_SIZE; + } + + /** + * Turns a player inventory slot into the slot the animation uses for it. + * + * @param inventorySize the size of the menu + * @param playerSlot the slot as Bukkit numbers it, 0 to 35 + * @return the slot in the animation space + */ + public static int fromPlayer(int inventorySize, int playerSlot) { + int display = playerSlot >= FIRST_STORAGE_SLOT + ? playerSlot - FIRST_STORAGE_SLOT + : (PLAYER_SIZE - FIRST_STORAGE_SLOT) + playerSlot; + + return inventorySize + display; + } + + /** + * Turns a slot of the animation space back into a player inventory slot. + * + * @param inventorySize the size of the menu + * @param slot the slot in the animation space + * @return the slot as Bukkit numbers it, 0 to 35 + */ + public static int toPlayer(int inventorySize, int slot) { + int display = slot - inventorySize; + + return display < PLAYER_SIZE - FIRST_STORAGE_SLOT + ? display + FIRST_STORAGE_SLOT + : display - (PLAYER_SIZE - FIRST_STORAGE_SLOT); + } + + /** + * Returns whether a slot of the animation space belongs to the player inventory. + * + * @param inventorySize the size of the menu + * @param slot the slot in the animation space + * @return true if the slot is in the player inventory + */ + public static boolean isPlayer(int inventorySize, int slot) { + return slot >= inventorySize; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java new file mode 100644 index 000000000..65488fc52 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java @@ -0,0 +1,352 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.order.SequentialSlotAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationSeed; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.item.AnimationKeyframe; +import fr.maxlego08.menu.api.animation.slot.settings.item.TemporaryItem; +import fr.maxlego08.menu.api.requirement.Action; +import fr.maxlego08.menu.api.requirement.Requirement; +import fr.maxlego08.menu.api.sound.SoundOption; +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * An animation written in code rather than in a file. + * + *

Extend it, override only what you care about, and register the result:

+ * + *
+ * public class GlowAnimation extends CustomAnimation {
+ *
+ *     public GlowAnimation() {
+ *         super(Key.key("myplugin", "glow"));
+ *     }
+ *
+ *     @Override
+ *     public SlotAnimation getAnimation() {
+ *         return new SpiralSlotAnimation();
+ *     }
+ *
+ *     @Override
+ *     public long getInterval() {
+ *         return 30;
+ *     }
+ *
+ *     @Override
+ *     public List<TemporaryItem> getTemporaryItems() {
+ *         return List.of(temporaryItem("flash", slots(10, 16), item, AnimationAnchor.START, AnimationAnchor.at(400)));
+ *     }
+ * }
+ *
+ * inventoryManager.getAnimationManager().registerAnimation(plugin, new GlowAnimation());
+ * 
+ * + *

Every hook has a default, so an animation may be as small as an ordering. Nothing here is + * required beyond the key.

+ */ +public abstract class CustomAnimation { + + private final Key key; + + /** + * @param key the key the animation is registered under, in the namespace of the plugin, such as + * {@code myplugin:glow}; the {@code animation} action plays it with {@code file: myplugin:glow} + */ + protected CustomAnimation(@NotNull Key key) { + this.key = key; + } + + /** + * Gets the key of this animation. + * + * @return the key + */ + @Contract(pure = true) + @NotNull + public final Key getKey() { + return this.key; + } + + /** + * The order the slots are revealed in. + * + * @return the ordering, sequential by default + */ + @NotNull + public SlotAnimation getAnimation() { + return new SequentialSlotAnimation(); + } + + /** + * Whether the order is played backwards. + * + * @return true to reverse it, false by default + */ + public boolean isReverse() { + return false; + } + + /** + * The delay between two steps, in milliseconds. + * + * @return the interval, 50 by default + */ + public long getInterval() { + return 50; + } + + /** + * The number of slots revealed on each step. + * + * @return the number of slots, 1 by default + */ + public int getSlotsPerStep() { + return 1; + } + + /** + * The delay before the first group starts, in milliseconds. + * + * @return the delay, none by default + */ + public long getDelay() { + return 0; + } + + /** + * The sound played when a slot is revealed. + * + * @return the sound, none by default + */ + @Nullable + public SoundOption getSound() { + return null; + } + + /** + * Whether the inventory ignores clicks while the animation plays. + * + * @return true to freeze it, which is the default + */ + public boolean isFreeze() { + return true; + } + + /** + * When the animation is allowed to play. + * + * @return the mode, every open by default + */ + @NotNull + public AnimationPlayMode getPlayMode() { + return AnimationPlayMode.ALWAYS; + } + + /** + * The groups that differ from the defaults above, keyed by group number. + * + *

Buttons pick their group with {@code animation.group}; a group missing from this map uses + * everything this class returns.

+ * + * @return the groups, none by default + */ + @NotNull + public Map getGroups() { + return Map.of(); + } + + /** + * Where the randomness of this animation comes from. + * + * @return the seed, a fresh roll on every play by default + */ + @NotNull + public AnimationSeed getSeed() { + return AnimationSeed.RANDOM; + } + + /** + * What runs when this animation starts. + * + * @return the actions, none by default + */ + @NotNull + public List getStartActions() { + return List.of(); + } + + /** + * What runs when this animation is over, however it ended. + * + * @return the actions, none by default + */ + @NotNull + public List getEndActions() { + return List.of(); + } + + /** + * What the player must satisfy for this animation to play. + * + * @return the requirement, none by default + */ + @Nullable + public Requirement getRequirement() { + return null; + } + + /** + * The items that exist only while the animation plays. + * + * @return the temporary items, none by default + */ + @NotNull + public List getTemporaryItems() { + return List.of(); + } + + /** + * Builds the settings this class describes. + * + * @return the settings + */ + @Contract(pure = true) + @NotNull + public final SlotAnimationSettings toSettings() { + SlotAnimationGroup defaults = new SlotAnimationGroup(this.getAnimation(), this.getInterval(), this.getSlotsPerStep(), this.getDelay(), this.getSound(), this.isReverse()); + + return new SlotAnimationSettings(defaults, this.getGroups(), this.isFreeze(), this.getPlayMode(), + this.getTemporaryItems(), this.getRequirement(), 100, List.of(), this.getSeed(), + this.getStartActions(), this.getEndActions()); + } + + /** + * Builds the animation this class describes, ready to be registered or played. + * + * @return the animation + */ + @Contract(pure = true) + @NotNull + public final Animation toAnimation() { + return new Animation(this.key, this.toSettings()); + } + + /** + * A group overriding some of the defaults of this class. + * + * @param animation the ordering, null to keep the one of this class + * @param interval the delay between two steps, in milliseconds + * @param slotsPerStep the number of slots revealed on each step + * @param delay the delay before the group starts + * @return the group + */ + @Contract(pure = true) + @NotNull + protected final SlotAnimationGroup group(@Nullable SlotAnimation animation, long interval, int slotsPerStep, long delay) { + return new SlotAnimationGroup(animation == null ? this.getAnimation() : animation, interval, slotsPerStep, delay, this.getSound(), this.isReverse()); + } + + /** + * A map of groups, in the order they are given. + * + * @param first the number of the first group + * @param groups the groups, starting at {@code first} + * @return the groups, keyed by number + */ + @Contract(pure = true) + @NotNull + protected final Map groups(int first, @NotNull SlotAnimationGroup... groups) { + Map map = new LinkedHashMap<>(); + for (int index = 0; index < groups.length; index++) { + map.put(first + index, groups[index]); + } + return map; + } + + /** + * A temporary item appearing once and going away once. + * + * @param name the name of the item, used to report errors + * @param slots the slots it occupies + * @param item the item to display + * @param show when it appears + * @param hide when it is removed + * @return the temporary item + */ + @Contract(pure = true) + @NotNull + protected final TemporaryItem temporaryItem(@NotNull String name, @NotNull List slots, @NotNull MenuItemStack item, @NotNull AnimationAnchor show, @NotNull AnimationAnchor hide) { + return new TemporaryItem(name, slots, List.of( + new AnimationKeyframe(show, item, null), + new AnimationKeyframe(hide, null, null))); + } + + /** + * A temporary item described by its keyframes. + * + * @param name the name of the item, used to report errors + * @param slots the slots it occupies + * @param frames what it does over time + * @return the temporary item + */ + @Contract(pure = true) + @NotNull + protected final TemporaryItem temporaryItem(@NotNull String name, @NotNull List slots, @NotNull AnimationKeyframe... frames) { + return new TemporaryItem(name, slots, List.of(frames)); + } + + /** + * A keyframe displaying an item from that moment on. + * + * @param at when it happens + * @param item the item to display + * @param sound the sound to play, or null + * @return the keyframe + */ + @Contract(pure = true) + @NotNull + protected final AnimationKeyframe frame(@NotNull AnimationAnchor at, @NotNull MenuItemStack item, @Nullable SoundOption sound) { + return new AnimationKeyframe(at, item, sound); + } + + /** + * A keyframe removing the temporary item. + * + * @param at when it happens + * @param sound the sound to play, or null + * @return the keyframe + */ + @Contract(pure = true) + @NotNull + protected final AnimationKeyframe removal(@NotNull AnimationAnchor at, @Nullable SoundOption sound) { + return new AnimationKeyframe(at, null, sound); + } + + /** + * The slots from one to another, both included. + * + * @param from the first slot + * @param to the last slot + * @return the slots + */ + @Contract(pure = true) + @NotNull + protected final List slots(int from, int to) { + List slots = new ArrayList<>(Math.max(0, to - from + 1)); + for (int slot = Math.min(from, to); slot <= Math.max(from, to); slot++) { + slots.add(slot); + } + return slots; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java new file mode 100644 index 000000000..03ba99cbf --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java @@ -0,0 +1,56 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Decides in which order the slots of an inventory are revealed when it opens. + * + *

An implementation only reorders the slots it is given; the timing and the number of slots + * revealed per step come from {@link SlotAnimationSettings}. Implementations must return every slot + * they receive, exactly once, so that no item is ever lost.

+ */ +public interface SlotAnimation { + + /** + * Orders the slots to reveal. + * + * @param inventorySize the number of slots being laid out: a menu and the player inventory + * together, so 90 for a double chest. Always a multiple of 9 when the menu + * is a chest, since both parts are + * @param slots the slots holding an item, in ascending order + * @return the same slots, in the order they must be revealed + */ + @NotNull + List order(int inventorySize, @NotNull List slots); + + /** + * Orders the slots, knowing who the animation is played for. + * + *

This is what the compiler calls. Override it instead of {@link #order(int, List)} when the + * order depends on the player or on the menu; the default simply forwards, so an implementation + * that does not care only has to write the simple one.

+ * + * @param context what to order, and who for + * @return the same slots, in the order they must be revealed + */ + @NotNull + default List order(@NotNull AnimationContext context) { + return this.order(context.inventorySize(), context.slots()); + } + + /** + * Writes the {@code type} of this animation and its options, the way its + * {@link fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader} reads them back. + * + * @param section the section of the group the animation belongs to + * @throws UnsupportedOperationException if this animation has no type a file can name, such as + * one built in code + */ + default void serialize(@NotNull ConfigurationSection section) { + throw new UnsupportedOperationException(this.getClass().getName() + " cannot be serialized"); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java new file mode 100644 index 000000000..9ffb81ec6 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java @@ -0,0 +1,32 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals one slot out of two first, then fills the gaps, so the inventory appears as a chequered + * pattern before it completes. + */ +public class CheckerboardSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "checkerboard"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> ((slot / width) + (slot % width)) % 2) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java new file mode 100644 index 000000000..74476680f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java @@ -0,0 +1,33 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots column by column, from left to right, each column top to bottom. + * + *

Unlike {@link VerticalSnakeSlotAnimation} every column is walked in the same direction.

+ */ +public class ColumnsSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "columns"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> slot % width) + .thenComparingInt(slot -> slot / width)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java new file mode 100644 index 000000000..6bc7496a2 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java @@ -0,0 +1,61 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Reveals the slots in the order they are listed. + * + *

The list uses the same syntax as the slots of a button, so a line may be a single slot or a + * range:

+ * + *
+ * type: CUSTOM
+ * order:
+ *   - 13
+ *   - 12
+ *   - 14
+ *   - 4-22
+ * 
+ * + *

A slot listed twice keeps its first position, and slots the list does not mention are revealed + * last, in ascending order, so a partial list still describes a complete animation.

+ */ +public class CustomSlotAnimation implements SlotAnimation { + + private final Map positionBySlot; + + /** + * @param order the slots in the order they must be revealed + */ + public CustomSlotAnimation(@NotNull List order) { + Set unique = new LinkedHashSet<>(order); + + this.positionBySlot = new HashMap<>(unique.size()); + int position = 0; + for (int slot : unique) { + this.positionBySlot.put(slot, position++); + } + } + + @Override + public void serialize(@NotNull ConfigurationSection section) { + List order = new ArrayList<>(this.positionBySlot.keySet()); + order.sort(Comparator.comparingInt(this.positionBySlot::get)); + + section.set("type", "custom"); + section.set("order", order); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> this.positionBySlot.getOrDefault(slot, Integer.MAX_VALUE)) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java new file mode 100644 index 000000000..97ddf7495 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java @@ -0,0 +1,39 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots closest to the middle of the inventory first, expanding outwards. + */ +public class FromCenterSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "from_center"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + int height = SlotGrid.height(inventorySize); + + double centerColumn = (width - 1) / 2.0; + double centerRow = (height - 1) / 2.0; + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingDouble((Integer slot) -> { + double columnDistance = (slot % width) - centerColumn; + double rowDistance = (slot / width) - centerRow; + return (columnDistance * columnDistance) + (rowDistance * rowDistance); + }) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java new file mode 100644 index 000000000..4ae0c0f4f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java @@ -0,0 +1,35 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots row by row, alternating the direction of each row, so the reveal snakes from the + * top left corner down to the bottom of the inventory. + */ +public class HorizontalSnakeSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "horizontal_snake"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> slot / width) + .thenComparingInt(slot -> { + int column = slot % width; + return (slot / width) % 2 == 0 ? column : width - 1 - column; + })); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java new file mode 100644 index 000000000..3fb536459 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java @@ -0,0 +1,70 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Reveals the slots in the order drawn in a matrix. + * + *

Each row of the matrix describes a row of the inventory, and each character a slot. Every + * distinct character is a step, and the steps are played in the order the characters sort in, so + * {@code 1} comes before {@code 2} and digits come before letters. A space means the slot is not + * part of the drawing.

+ * + *
+ * matrix:
+ *   - "111111111"
+ *   - "122222221"
+ *   - "111111111"
+ * 
+ * + *

Slots the matrix does not cover are revealed last, in ascending order, so a matrix shorter than + * the inventory still describes a complete animation.

+ */ +public class MatrixSlotAnimation implements SlotAnimation { + + private final List rows; + private final Map stepBySlot; + + /** + * @param rows the rows of the matrix, at most 9 characters each + */ + public MatrixSlotAnimation(@NotNull List rows) { + this.rows = List.copyOf(rows); + Map charBySlot = new HashMap<>(); + TreeSet characters = new TreeSet<>(); + + for (int row = 0; row < rows.size(); row++) { + String line = rows.get(row); + for (int column = 0; column < Math.min(line.length(), SlotGrid.CHEST_WIDTH); column++) { + char character = line.charAt(column); + if (character == ' ') continue; + + charBySlot.put((row * SlotGrid.CHEST_WIDTH) + column, character); + characters.add(character); + } + } + + List ordered = new ArrayList<>(characters); + this.stepBySlot = new HashMap<>(charBySlot.size()); + charBySlot.forEach((slot, character) -> this.stepBySlot.put(slot, ordered.indexOf(character))); + } + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "matrix"); + section.set("matrix", this.rows); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> this.stepBySlot.getOrDefault(slot, Integer.MAX_VALUE)) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java new file mode 100644 index 000000000..1aa09e818 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java @@ -0,0 +1,42 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Reveals the slots in a random order. + */ +public class RandomSlotAnimation implements SlotAnimation { + + private final Random random; + + public RandomSlotAnimation() { + this(null); + } + + /** + * @param random the source of randomness, or null to use {@link ThreadLocalRandom} + */ + public RandomSlotAnimation(Random random) { + this.random = random; + } + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "random"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + List ordered = new ArrayList<>(slots); + Collections.shuffle(ordered, this.random == null ? ThreadLocalRandom.current() : this.random); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java new file mode 100644 index 000000000..137cb73e7 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java @@ -0,0 +1,23 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Reveals the slots from the first to the last, left to right and row by row. + */ +public class SequentialSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "sequential"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + return List.copyOf(slots); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java new file mode 100644 index 000000000..0c10d9be1 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java @@ -0,0 +1,39 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; + +/** + * Helpers shared by the grid based {@link SlotAnimation} implementations. + * + *

Inventories whose size is a multiple of 9 are treated as a 9 wide grid, which covers every + * chest. Any other size is treated as a single row, so the grid based animations degrade to a plain + * left to right reveal instead of computing meaningless coordinates.

+ */ +public final class SlotGrid { + + public static final int CHEST_WIDTH = 9; + + private SlotGrid() { + } + + /** + * Gets the width of the grid for an inventory of the given size. + * + * @param inventorySize the inventory size + * @return the number of columns + */ + public static int width(int inventorySize) { + return inventorySize > 0 && inventorySize % CHEST_WIDTH == 0 ? CHEST_WIDTH : Math.max(1, inventorySize); + } + + /** + * Gets the number of rows of the grid for an inventory of the given size. + * + * @param inventorySize the inventory size + * @return the number of rows + */ + public static int height(int inventorySize) { + int width = width(inventorySize); + return Math.max(1, (int) Math.ceil((double) inventorySize / width)); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java new file mode 100644 index 000000000..789dc2059 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java @@ -0,0 +1,56 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Reveals the slots by walking the inventory in an inward spiral, starting from the top left corner + * and turning clockwise. + */ +public class SpiralSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "spiral"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + int height = SlotGrid.height(inventorySize); + + Set remaining = new TreeSet<>(slots); + Set ordered = new LinkedHashSet<>(slots.size()); + + int top = 0, bottom = height - 1, left = 0, right = width - 1; + while (top <= bottom && left <= right) { + for (int column = left; column <= right; column++) this.take(ordered, remaining, top * width + column); + top++; + + for (int row = top; row <= bottom; row++) this.take(ordered, remaining, row * width + right); + right--; + + if (top <= bottom) { + for (int column = right; column >= left; column--) this.take(ordered, remaining, bottom * width + column); + bottom--; + } + + if (left <= right) { + for (int row = bottom; row >= top; row--) this.take(ordered, remaining, row * width + left); + left++; + } + } + + ordered.addAll(remaining); + return new ArrayList<>(ordered); + } + + private void take(Set ordered, Set remaining, int slot) { + if (remaining.remove(slot)) { + ordered.add(slot); + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java new file mode 100644 index 000000000..17385c6bd --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java @@ -0,0 +1,36 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots column by column, alternating the direction of each column, so the reveal snakes + * from the top left corner across to the right of the inventory. + */ +public class VerticalSnakeSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "vertical_snake"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + int height = SlotGrid.height(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> slot % width) + .thenComparingInt(slot -> { + int row = slot / width; + return (slot % width) % 2 == 0 ? row : height - 1 - row; + })); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java new file mode 100644 index 000000000..a975b56fb --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java @@ -0,0 +1,31 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots as a diagonal sweep running from the top left corner to the bottom right one. + */ +public class WaveSlotAnimation implements SlotAnimation { + + @Override + public void serialize(@NotNull ConfigurationSection section) { + section.set("type", "wave"); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> (slot / width) + (slot % width)) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java new file mode 100644 index 000000000..2c9905928 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java @@ -0,0 +1,186 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Map; + +/** + * A point in time inside an animation, written in whichever unit suits the author. + * + * + * + * + * + * + * + * + *
Accepted forms
{@code 250}250 milliseconds after the animation started
{@code group:2} or {@code group:2.start}the moment group 2 starts
{@code group:2.end}the moment group 2 is over
{@code start}the beginning of the animation
{@code end}the end of the last group
+ * + *

An anchor is parsed when the inventory is loaded, and resolved to a time when the animation is + * compiled, because the boundaries of the groups are only known then.

+ * + * @param type the kind of anchor + * @param millis the time, only used by {@link Type#MILLIS} + * @param group the group, only used by {@link Type#GROUP_START} and {@link Type#GROUP_END} + */ +public record AnimationAnchor(@NotNull Type type, long millis, int group) { + + public enum Type {START, END, MILLIS, GROUP_START, GROUP_END} + + /** + * The beginning of the animation. + */ + public static final AnimationAnchor START = new AnimationAnchor(Type.START, 0, 0); + + /** + * The end of the last group. + */ + public static final AnimationAnchor END = new AnimationAnchor(Type.END, 0, 0); + + /** + * An anchor at a fixed time. + * + * @param millis the time in milliseconds + * @return the anchor + */ + @Contract(pure = true) + @NotNull + public static AnimationAnchor at(long millis) { + return new AnimationAnchor(Type.MILLIS, Math.max(0, millis), 0); + } + + /** + * An anchor on the start of a group. + * + * @param group the group number + * @return the anchor + */ + @Contract(pure = true) + @NotNull + public static AnimationAnchor groupStart(int group) { + return new AnimationAnchor(Type.GROUP_START, 0, group); + } + + /** + * An anchor on the end of a group. + * + * @param group the group number + * @return the anchor + */ + @Contract(pure = true) + @NotNull + public static AnimationAnchor groupEnd(int group) { + return new AnimationAnchor(Type.GROUP_END, 0, group); + } + + /** + * Gets the compact form of this anchor, the way {@link #parseValue(Object)} reads it back. + * + * @return a number of milliseconds, or the text of the anchor + */ + @Contract(pure = true) + @NotNull + public Object serialize() { + return switch (this.type) { + case START -> "start"; + case END -> "end"; + case MILLIS -> this.millis; + case GROUP_START -> "group:" + this.group; + case GROUP_END -> "group:" + this.group + ".end"; + }; + } + + /** + * Parses an anchor written in a configuration file, in either of its two forms. + * + *

The compact form is a single value, parsed by {@link #parse(String)}. The explicit form is a + * section, given here as its values:

+ * + *
+     * show:
+     *   group: 2
+     *   at: end      # start by default
+     *
+     * show:
+     *   at: 250      # anything the compact form accepts
+     * 
+ * + * @param value the value to parse: a section as a map, or anything else for the compact form + * @return the anchor, or null if the value is not a valid anchor + */ + @Contract(pure = true) + @Nullable + public static AnimationAnchor parseValue(@Nullable Object value) { + if (value == null) return null; + + if (value instanceof Map map) { + Object group = map.get("group"); + Object at = map.get("at"); + + if (group != null) { + int groupNumber; + try { + groupNumber = group instanceof Number number ? number.intValue() : Integer.parseInt(group.toString().trim()); + } catch (NumberFormatException exception) { + return null; + } + + if (at == null) return groupStart(groupNumber); + String boundary = at.toString().trim().toLowerCase(Locale.ROOT); + if (boundary.equals("start")) return groupStart(groupNumber); + if (boundary.equals("end")) return groupEnd(groupNumber); + return null; + } + + return at == null ? null : parse(at.toString()); + } + + return parse(value.toString()); + } + + /** + * Parses an anchor written in its compact form. + * + * @param value the value to parse + * @return the anchor, or null if the value is not a valid anchor + */ + @Contract(pure = true) + @Nullable + public static AnimationAnchor parse(@Nullable String value) { + if (value == null) return null; + + String trimmed = value.trim().toLowerCase(Locale.ROOT); + if (trimmed.isEmpty()) return null; + + if (trimmed.equals("start")) return START; + if (trimmed.equals("end")) return END; + + if (trimmed.startsWith("group:")) { + String rest = trimmed.substring("group:".length()); + + boolean end = false; + if (rest.endsWith(".end")) { + end = true; + rest = rest.substring(0, rest.length() - ".end".length()); + } else if (rest.endsWith(".start")) { + rest = rest.substring(0, rest.length() - ".start".length()); + } + + try { + int group = Integer.parseInt(rest.trim()); + return end ? groupEnd(group) : groupStart(group); + } catch (NumberFormatException exception) { + return null; + } + } + + try { + return at(Long.parseLong(trimmed)); + } catch (NumberFormatException exception) { + return null; + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java new file mode 100644 index 000000000..18ee8dfdb --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java @@ -0,0 +1,107 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Where the randomness of an animation comes from. + * + *

By default every play rolls afresh. Seeding it from the player instead turns variety into + * personality: a given player always gets the same variant, the same scatter and the same choices, + * so the menu looks like theirs rather than looking different every time they open it.

+ * + * @param type the kind of seed + * @param value the seed itself, only used by {@link Type#FIXED} + */ +public record AnimationSeed(@NotNull Type type, long value) { + + public enum Type { + + /** + * A fresh roll on every play. + */ + RANDOM, + + /** + * The same roll every time, for a given player and a given animation. + */ + PLAYER, + + /** + * The same roll for everyone, every time. + */ + FIXED + } + + /** + * A fresh roll on every play, which is what an animation does unless it says otherwise. + */ + public static final AnimationSeed RANDOM = new AnimationSeed(Type.RANDOM, 0); + + /** + * Parses a seed from a configuration value. + * + *

{@code random} rolls afresh, {@code player} is stable per player, and a number is stable + * for everyone.

+ * + * @param value the value to parse, can be null + * @return the seed, {@link #RANDOM} if the value is null or not understood + */ + @Contract(pure = true) + @NotNull + public static AnimationSeed parse(@Nullable String value) { + if (value == null) return RANDOM; + + String trimmed = value.trim().toLowerCase(Locale.ROOT); + if (trimmed.isEmpty() || trimmed.equals("random")) return RANDOM; + if (trimmed.equals("player")) return new AnimationSeed(Type.PLAYER, 0); + + try { + return new AnimationSeed(Type.FIXED, Long.parseLong(trimmed)); + } catch (NumberFormatException exception) { + return RANDOM; + } + } + + /** + * Gets the value this seed is written as, the way {@link #parse(String)} reads it back. + * + * @return {@code random}, {@code player}, or the fixed number + */ + @Contract(pure = true) + @NotNull + public Object serialize() { + return switch (this.type) { + case RANDOM -> "random"; + case PLAYER -> "player"; + case FIXED -> this.value; + }; + } + + /** + * Builds the source the animation takes its rolls from. + * + *

The name of the animation is mixed in, so two animations seeded from the same player do not + * end up producing the same pattern as one another.

+ * + * @param playerId the player the animation is played for, null outside of one + * @param animationName the name of the animation + * @return the source + */ + @NotNull + public Random create(@Nullable UUID playerId, @NotNull String animationName) { + return switch (this.type) { + case RANDOM -> ThreadLocalRandom.current(); + case FIXED -> new Random(this.value + animationName.hashCode()); + case PLAYER -> playerId == null + ? ThreadLocalRandom.current() + : new Random((playerId.getMostSignificantBits() ^ playerId.getLeastSignificantBits()) * 31 + animationName.hashCode()); + }; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java new file mode 100644 index 000000000..e5a937597 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java @@ -0,0 +1,32 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +/** + * One of the animations a menu may play, and how likely it is compared to the others. + * + *

A variant is picked when the animation is about to play, so a menu can spiral one time and + * sweep the next.

+ * + * @param name the name of the variant in the configuration, used to report errors + * @param settings what that variant does + * @param weight how likely it is; a variant of weight 3 is picked three times as often as one of + * weight 1 + */ +public record AnimationVariant(@NotNull String name, @NotNull SlotAnimationSettings settings, int weight) { + + public AnimationVariant { + weight = Math.max(1, weight); + } + + /** + * Writes this variant into its section of {@code variants}: its settings, and its weight. + * + * @param section the section named after the variant + */ + public void serialize(@NotNull ConfigurationSection section) { + this.settings.serialize(section); + if (this.weight != 1) section.set("weight", this.weight); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java new file mode 100644 index 000000000..ac05ae0f7 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java @@ -0,0 +1,73 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.sound.SoundOption; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * How a single button takes part in the opening animation of its inventory. + * + * @param enabled whether the slots of the button are animated at all; a disabled button is displayed + * immediately, even while the rest of the inventory is still being revealed + * @param group the group the button belongs to, groups are played in ascending order + * @param delay an extra delay for the slots of this button, in milliseconds, counted from the + * start of its group + * @param sound the sound played when a slot of this button is revealed, overriding the one of its + * group, or null to use the group one + * @param allowTemporaryItems whether a temporary item may be drawn over the slots of this button; a + * button holding items a player can take never allows it, so that nothing + * decorative can be picked up + */ +public record ButtonAnimationSettings(boolean enabled, int group, long delay, @Nullable SoundOption sound, boolean allowTemporaryItems) { + + public ButtonAnimationSettings(boolean enabled, int group, long delay, @Nullable SoundOption sound) { + this(enabled, group, delay, sound, true); + } + + public ButtonAnimationSettings(boolean enabled, int group, long delay) { + this(enabled, group, delay, null, true); + } + + /** + * Used by every button that does not configure its animation. + */ + public static final ButtonAnimationSettings DEFAULT = new ButtonAnimationSettings(true, 1, 0, null, true); + + /** + * Used by a button configured with {@code animation: false}. + */ + public static final ButtonAnimationSettings DISABLED = new ButtonAnimationSettings(false, 1, 0, null, true); + + /** + * Used by a button that takes part in the animation but refuses to be covered by a temporary + * item, and that configured nothing else. + */ + public static final ButtonAnimationSettings NOT_COVERABLE = new ButtonAnimationSettings(true, 1, 0, null, false); + + public ButtonAnimationSettings { + delay = Math.max(0, delay); + } + + /** + * Writes the {@code animation} key of a button: nothing when the button keeps the settings of + * its class, {@code false} when it does not animate, otherwise a section with what differs. + * + * @param buttonSection the section of the button + * @param allowTemporaryItemsByDefault whether the class of the button lets temporary items cover it + */ + public void serialize(@NotNull ConfigurationSection buttonSection, boolean allowTemporaryItemsByDefault) { + if (this.equals(allowTemporaryItemsByDefault ? DEFAULT : NOT_COVERABLE)) return; + + if (!this.enabled) { + buttonSection.set("animation", false); + return; + } + + ConfigurationSection section = buttonSection.createSection("animation"); + if (this.group != 1) section.set("group", this.group); + if (this.delay != 0) section.set("delay", this.delay); + if (this.sound != null) this.sound.serialize(section.createSection("sound")); + if (this.allowTemporaryItems != allowTemporaryItemsByDefault) section.set("allow-temporary-items", this.allowTemporaryItems); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java new file mode 100644 index 000000000..6b2325d3e --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java @@ -0,0 +1,82 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * How one group of slots is revealed. + * + *

Ordering is a property of the group, not of a single button: a group is revealed as a whole, so + * an animation such as a spiral can span several buttons. A button only chooses the group it belongs + * to, and may add a delay of its own.

+ * + * @param animation the order in which the slots of the group are revealed + * @param interval the delay between two steps, in milliseconds + * @param slotsPerStep the number of slots revealed on each step + * @param delay the delay before the group starts, in milliseconds, counted from the end of + * the previous group + * @param sound the sound played when a slot of the group is revealed, or null for a silent + * group + * @param reverse whether the order produced by the animation is played backwards + * @param jitter how far a step may land from its scheduled time, in milliseconds, so a reveal + * reads as organic rather than mechanical; 0 keeps it exact + */ +public record SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay, @Nullable SoundOption sound, boolean reverse, long jitter) { + + /** + * The delay between two steps when nothing sets one, in milliseconds. + */ + public static final long DEFAULT_INTERVAL = 50; + + public SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay, + @Nullable SoundOption sound, boolean reverse) { + this(animation, interval, slotsPerStep, delay, sound, reverse, 0); + } + + public SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay, @Nullable SoundOption sound) { + this(animation, interval, slotsPerStep, delay, sound, false, 0); + } + + public SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay) { + this(animation, interval, slotsPerStep, delay, null, false, 0); + } + + public SlotAnimationGroup { + interval = Math.max(1, interval); + slotsPerStep = Math.max(1, slotsPerStep); + delay = Math.max(0, delay); + jitter = Math.max(0, jitter); + } + + /** + * Writes this group, leaving out what it inherits: a group of {@code groups} only overrides the + * default group of its animation, so only what differs from {@code defaults} is written. + * + * @param section the section of the group + * @param defaults the group this one inherits from, or null for the default group itself + * @throws UnsupportedOperationException if the animation of the group cannot be serialized + */ + public void serialize(@NotNull ConfigurationSection section, @Nullable SlotAnimationGroup defaults) { + if (defaults == null || this.animation != defaults.animation) this.animation.serialize(section); + + long inheritedInterval = defaults == null ? DEFAULT_INTERVAL : defaults.interval; + if (this.interval != inheritedInterval) section.set("interval", this.interval); + + int inheritedSlotsPerStep = defaults == null ? 1 : defaults.slotsPerStep; + if (this.slotsPerStep != inheritedSlotsPerStep) section.set("slots-per-step", this.slotsPerStep); + + if (this.delay != 0) section.set("delay", this.delay); + + SoundOption inheritedSound = defaults == null ? null : defaults.sound; + if (this.sound != null && this.sound != inheritedSound) this.sound.serialize(section.createSection("sound")); + + boolean inheritedReverse = defaults != null && defaults.reverse; + if (this.reverse != inheritedReverse) section.set("reverse", this.reverse); + + long inheritedJitter = defaults == null ? 0 : defaults.jitter; + if (this.jitter != inheritedJitter) section.set("interval-jitter", this.jitter); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java new file mode 100644 index 000000000..0cb1c2799 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java @@ -0,0 +1,187 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.animation.slot.AnimationPlayMode; +import fr.maxlego08.menu.api.animation.slot.settings.item.TemporaryItem; +import fr.maxlego08.menu.api.requirement.Action; +import fr.maxlego08.menu.api.requirement.Permissible; +import fr.maxlego08.menu.api.requirement.Requirement; +import fr.maxlego08.menu.api.utils.SectionSerializable; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * Settings of the animation played when an inventory opens. + * + *

Every animated slot belongs to a group, group 1 by default. Groups are played one after the + * other, in ascending order, and each of them may override how it is revealed. A group without an + * explicit configuration uses {@code defaults}.

+ * + * @param defaults the configuration used by the groups that do not declare one + * @param groups the per group overrides, keyed by group number + * @param freeze whether the inventory ignores clicks while the animation plays + * @param play when the animation is allowed to play + * @param temporaryItems the items that exist only while the animation plays + * @param requirement what the player must satisfy for the animation to play, null for no + * condition + * @param chance the chance of the animation playing at all, in percent + * @param variants the alternatives to pick one from when the animation plays; when it is not + * empty the picked one describes what happens, and only the chance and the + * requirement of this one still apply + * @param seed where the randomness of the animation comes from + * @param startActions what runs when the animation starts, after it is known that it will play + * @param endActions what runs when it is over, however it ended + * @param cooldown the minimum time between two plays for one player, in milliseconds; 0 for + * none. Remembered in the database, so it survives a reconnect + * @param files the files of the animations folder these settings were read from, when the + * configuration only named them with {@code file}, {@code files} or + * {@code extends}; empty when anything was written next to them, or when the + * settings were written in place + */ +public record SlotAnimationSettings( + @NotNull SlotAnimationGroup defaults, + @NotNull Map groups, + boolean freeze, + @NotNull AnimationPlayMode play, + @NotNull List temporaryItems, + @Nullable Requirement requirement, + float chance, + @NotNull List variants, + @NotNull AnimationSeed seed, + @NotNull List startActions, + @NotNull List endActions, + long cooldown, + @NotNull List files +) implements SectionSerializable { + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants, @NotNull AnimationSeed seed, @NotNull List startActions, @NotNull List endActions, long cooldown) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, seed, startActions, endActions, cooldown, List.of()); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants, @NotNull AnimationSeed seed, @NotNull List startActions, @NotNull List endActions) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, seed, startActions, endActions, 0); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants, @NotNull AnimationSeed seed) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, seed, List.of(), List.of(), 0); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, AnimationSeed.RANDOM, List.of(), List.of()); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement) { + this(defaults, groups, freeze, play, temporaryItems, requirement, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, + boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems) { + this(defaults, groups, freeze, play, temporaryItems, null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play) { + this(defaults, groups, freeze, play, List.of(), null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze) { + this(defaults, groups, freeze, AnimationPlayMode.ALWAYS, List.of(), null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, boolean freeze) { + this(defaults, Map.of(), freeze, AnimationPlayMode.ALWAYS, List.of(), null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings { + groups = Map.copyOf(groups); + temporaryItems = List.copyOf(temporaryItems); + variants = List.copyOf(variants); + startActions = List.copyOf(startActions); + endActions = List.copyOf(endActions); + cooldown = Math.max(0, cooldown); + files = List.copyOf(files); + } + + /** + * Returns these settings, remembering that they were read from files of the animations folder, + * so that they are written back as a reference rather than in full. + * + * @param files the names of the files + * @return the same settings, with their files + */ + @NotNull + public SlotAnimationSettings withFiles(@NotNull List files) { + return new SlotAnimationSettings(this.defaults, this.groups, this.freeze, this.play, this.temporaryItems, this.requirement, + this.chance, this.variants, this.seed, this.startActions, this.endActions, this.cooldown, files); + } + + /** + * Writes these settings the way an animation section is read back: a reference to their files + * when they were only read from files, otherwise every value that differs from its default. + * + * @param section the animation section + * @throws UnsupportedOperationException if a group uses an animation that cannot be serialized + */ + @Override + public void serialize(@NotNull ConfigurationSection section) { + if (!this.files.isEmpty()) { + section.set("file", this.files.size() == 1 ? this.files.getFirst() : this.files); + return; + } + + this.defaults.serialize(section, null); + + if (!this.groups.isEmpty()) { + ConfigurationSection groupsSection = section.createSection("groups"); + new TreeMap<>(this.groups).forEach((number, group) -> group.serialize(groupsSection.createSection(String.valueOf(number)), this.defaults)); + } + + if (!this.freeze) section.set("freeze", false); + if (this.play != AnimationPlayMode.ALWAYS) section.set("play", this.play.name()); + + if (!this.temporaryItems.isEmpty()) { + ConfigurationSection itemsSection = section.createSection("temporary-items"); + this.temporaryItems.forEach(temporaryItem -> temporaryItem.serialize(itemsSection.createSection(temporaryItem.name()))); + } + + if (this.requirement != null) this.requirement.serialize(section.createSection("requirement")); + if (this.chance != 100) section.set("chance", this.chance); + + if (!this.variants.isEmpty()) { + ConfigurationSection variantsSection = section.createSection("variants"); + this.variants.forEach(variant -> variant.serialize(variantsSection.createSection(variant.name()))); + } + + if (!this.seed.equals(AnimationSeed.RANDOM)) section.set("seed", this.seed.serialize()); + if (!this.startActions.isEmpty()) section.set("start-actions", Permissible.serializeActions(this.startActions)); + if (!this.endActions.isEmpty()) section.set("end-actions", Permissible.serializeActions(this.endActions)); + if (this.cooldown > 0) section.set("cooldown", this.cooldown); + } + + /** + * Gets the configuration of a group. + * + * @param group the group number + * @return the configuration of that group, or {@link #defaults()} if it declares none + */ + @NotNull + public SlotAnimationGroup group(int group) { + return this.groups.getOrDefault(group, this.defaults); + } + + /** + * Whether the player having seen this animation has to be remembered beyond the session. + * + * @return true when the mode or the cooldown needs the database + */ + public boolean needsPersistence() { + return this.play.isPersistent() || this.cooldown > 0; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java new file mode 100644 index 000000000..2996406c5 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java @@ -0,0 +1,175 @@ +package fr.maxlego08.menu.api.animation.slot.settings.idle; + +import fr.maxlego08.menu.api.MenuItemStack; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * An animation a button plays on its own slots for as long as the menu stays open. + * + *

This is not the opening animation: there is no timeline, no group and no freeze. The frames + * are simply cycled on the slots of the button, which keeps working normally throughout.

+ * + *

The cycle only starts once the opening animation has revealed the slot, so the two never fight + * over it.

+ * + * @param frames the frames to cycle through, in order + * @param interval the delay between two frames, in milliseconds, used by every frame that does not + * declare a duration of its own + * @param loop whether the cycle restarts once it reaches the end; when false the button goes + * back to its real item and the cycle stops + * @param random whether the next frame is picked at random rather than taken in order + * @param files the files of the animations folder this animation was read from, when the button + * only named them; empty when anything was written next to them, or when it was + * written in place + */ +public record ButtonIdleAnimation(@NotNull List frames, long interval, boolean loop, boolean random, @NotNull List files) { + + /** + * The delay between two frames when nothing sets one, in milliseconds. + */ + public static final long DEFAULT_INTERVAL = 200; + + public ButtonIdleAnimation(@NotNull List frames, long interval, boolean loop, boolean random) { + this(frames, interval, loop, random, List.of()); + } + + public ButtonIdleAnimation(@NotNull List frames, long interval, boolean loop) { + this(frames, interval, loop, false); + } + + public ButtonIdleAnimation { + frames = List.copyOf(frames); + interval = Math.max(1, interval); + files = List.copyOf(files); + } + + /** + * Returns this animation, remembering that it was read from files of the animations folder, so + * that it is written back as a reference rather than in full. + * + * @param files the names of the files + * @return the same animation, with its files + */ + @NotNull + public ButtonIdleAnimation withFiles(@NotNull List files) { + return new ButtonIdleAnimation(this.frames, this.interval, this.loop, this.random, files); + } + + /** + * Writes this animation into the {@code idle-animation} section of its button: a reference to + * its files when it was only read from files, otherwise its frames and options. + * + * @param section the idle animation section + */ + public void serialize(@NotNull ConfigurationSection section) { + if (!this.files.isEmpty()) { + section.set("file", this.files.size() == 1 ? this.files.getFirst() : this.files); + return; + } + + ConfigurationSection itemsSection = section.createSection("items"); + for (int index = 0; index < this.frames.size(); index++) { + this.frames.get(index).serialize(itemsSection.createSection(String.valueOf(index + 1))); + } + + if (this.interval != DEFAULT_INTERVAL) section.set("interval", this.interval); + if (!this.loop) section.set("loop", false); + if (this.random) section.set("random", true); + } + + /** + * An animation cycling plain items, each lasting the interval and playing no sound. + * + * @param items the items to cycle through, in order + * @param interval the delay between two items, in milliseconds + * @param loop whether the cycle restarts once it reaches the end + * @param random whether the next item is picked at random rather than taken in order + * @return the animation + */ + @NotNull + public static ButtonIdleAnimation of(@NotNull List items, long interval, boolean loop, boolean random) { + return new ButtonIdleAnimation(items.stream().map(IdleFrame::new).toList(), interval, loop, random); + } + + /** + * An animation cycling plain items in order. + * + * @param items the items to cycle through, in order + * @param interval the delay between two items, in milliseconds + * @param loop whether the cycle restarts once it reaches the end + * @return the animation + */ + @NotNull + public static ButtonIdleAnimation of(@NotNull List items, long interval, boolean loop) { + return of(items, interval, loop, false); + } + + /** + * The items of the frames, in order. + * + * @return the items + */ + @NotNull + public List items() { + return this.frames.stream().map(IdleFrame::item).toList(); + } + + /** + * Gets the frame to display on a given turn. + * + * @param index the turn, starting at 0 + * @return the frame + */ + @NotNull + public IdleFrame frame(int index) { + return this.frames.get(Math.floorMod(index, this.frames.size())); + } + + /** + * Gets the frame to display on a given turn, picking at random when the cycle asks for it. + * + * @param index the turn, starting at 0 + * @param random the source to pick from + * @return the frame + */ + @NotNull + public IdleFrame frame(int index, @NotNull java.util.Random random) { + return this.random ? this.frames.get(random.nextInt(this.frames.size())) : this.frame(index); + } + + /** + * Gets the item to display on a given turn. + * + * @param index the turn, starting at 0 + * @return the item + */ + @NotNull + public MenuItemStack item(int index) { + return this.frame(index).item(); + } + + /** + * Gets the item to display on a given turn, picking at random when the cycle asks for it. + * + * @param index the turn, starting at 0 + * @param random the source to pick from + * @return the item + */ + @NotNull + public MenuItemStack item(int index, @NotNull java.util.Random random) { + return this.frame(index, random).item(); + } + + /** + * Returns whether the cycle is over. + * + * @param index the turn, starting at 0 + * @return true if there is nothing left to display + */ + public boolean isOver(int index) { + return !this.loop && index >= this.frames.size(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java new file mode 100644 index 000000000..efc77ac29 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java @@ -0,0 +1,56 @@ +package fr.maxlego08.menu.api.animation.slot.settings.idle; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * One step of a {@link ButtonIdleAnimation}. + * + *

A frame holds how long it stays on screen and what it sounds like, so a cycle can hold a + * picture for a while and then flash through two others, rather than giving every picture the same + * share of the time.

+ * + * @param item the item to display + * @param duration how long the frame stays, in milliseconds; 0 uses the interval of the animation + * @param sound the sound played when the frame is displayed, or null for a silent frame + */ +public record IdleFrame(@NotNull MenuItemStack item, long duration, @Nullable SoundOption sound) { + + /** + * A frame lasting the interval of its animation, with no sound of its own. + * + * @param item the item to display + */ + public IdleFrame(@NotNull MenuItemStack item) { + this(item, 0, null); + } + + public IdleFrame { + duration = Math.max(0, duration); + } + + /** + * How long the frame lasts, falling back to the interval of the animation when it declares no + * duration of its own. + * + * @param interval the interval of the animation, in milliseconds + * @return the duration to use, in milliseconds + */ + public long duration(long interval) { + return this.duration > 0 ? this.duration : interval; + } + + /** + * Writes this frame into its section of the idle animation's {@code items}. + * + * @param section the section of the frame + */ + public void serialize(@NotNull ConfigurationSection section) { + this.item.serialize(section); + if (this.duration != 0) section.set("duration", this.duration); + if (this.sound != null) this.sound.serialize(section.createSection("sound")); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java new file mode 100644 index 000000000..1c7a56568 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java @@ -0,0 +1,70 @@ +package fr.maxlego08.menu.api.animation.slot.settings.item; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * One moment in the life of a {@link TemporaryItem}: what it looks like from then on, or the moment + * it goes away. + * + * @param at when the keyframe happens + * @param item the item to display from then on, or null to remove the temporary item + * @param sound the sound to play, or null for a silent keyframe + * @param chance the chance of the keyframe happening, in percent. A keyframe that removes the item + * ignores it: an item that might not be removed would be left behind + * @param choices the items to pick from, one of which is displayed; when it is not empty it is used + * instead of {@code item} + */ +public record AnimationKeyframe(@NotNull AnimationAnchor at, @Nullable MenuItemStack item, @Nullable SoundOption sound, float chance, @NotNull List choices) { + + public AnimationKeyframe(@NotNull AnimationAnchor at, @Nullable MenuItemStack item, @Nullable SoundOption sound, float chance) { + this(at, item, sound, chance, List.of()); + } + + public AnimationKeyframe(@NotNull AnimationAnchor at, @Nullable MenuItemStack item, @Nullable SoundOption sound) { + this(at, item, sound, 100, List.of()); + } + + public AnimationKeyframe { + choices = List.copyOf(choices); + } + + /** + * Returns whether this keyframe removes the temporary item instead of changing it. + * + * @return true if the item goes away + */ + public boolean isRemoval() { + return this.item == null && this.choices.isEmpty(); + } + + /** + * Writes this keyframe the way a {@code frames} entry is read back. + * + * @param section the section of the keyframe + */ + public void serialize(@NotNull ConfigurationSection section) { + section.set("at", this.at.serialize()); + + if (this.isRemoval()) { + section.set("remove", true); + } else { + if (this.item != null) this.item.serialize(section.createSection("item")); + if (!this.choices.isEmpty()) { + ConfigurationSection choicesSection = section.createSection("choices"); + for (int index = 0; index < this.choices.size(); index++) { + this.choices.get(index).serialize(choicesSection.createSection(String.valueOf(index + 1))); + } + } + if (this.chance != 100) section.set("chance", this.chance); + } + + if (this.sound != null) this.sound.serialize(section.createSection("sound")); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java new file mode 100644 index 000000000..4410ba095 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java @@ -0,0 +1,33 @@ +package fr.maxlego08.menu.api.animation.slot.settings.item; + +import fr.maxlego08.menu.api.MenuItemStack; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +/** + * One of the items a keyframe may display, and how likely it is compared to the others. + * + * @param item the item + * @param weight how likely it is; an item of weight 3 is picked three times as often as one of + * weight 1 + */ +public record ItemChoice(@NotNull MenuItemStack item, int weight) { + + public ItemChoice(@NotNull MenuItemStack item) { + this(item, 1); + } + + public ItemChoice { + weight = Math.max(1, weight); + } + + /** + * Writes the item and its weight into the section of the choice. + * + * @param section the section of the choice + */ + public void serialize(@NotNull ConfigurationSection section) { + this.item.serialize(section); + if (this.weight != 1) section.set("weight", this.weight); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java new file mode 100644 index 000000000..287010801 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java @@ -0,0 +1,74 @@ +package fr.maxlego08.menu.api.animation.slot.settings.item; + +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * An item that exists only while the opening animation plays. + * + *

A temporary item is not a button: it has no click, no requirement, and it is gone once the + * animation is over. It may be placed on a slot a button owns, in which case it hides that button + * until it is removed, and it may also be placed on a slot no button will ever occupy.

+ * + *

Its life is described by its keyframes. The common case of an item appearing once and going + * away once is written with {@code show} and {@code hide} in the configuration, and loaded as the + * two keyframes it stands for, so there is a single behaviour to reason about.

+ * + * @param name the name of the item in the configuration, used to report errors + * @param slots the slots the item occupies + * @param frames what the item does over time, in the order it was written + * @param chance the chance of the item appearing at all, in percent; rolled once when the + * animation is compiled, so an item that wins appears for all of its keyframes + * @param chancePerSlot whether the chance, and the choice of item, are rolled once for the whole + * item or separately for each of its slots + * @param count how many of its slots the item uses, picked at random; 0 uses all of them + * @param useCache whether the item may be served from its build cache, like the + * {@code use-cache} option of a button; false rebuilds it on every frame, + * which is what an item whose content changes while the animation plays needs + */ +public record TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance, boolean chancePerSlot, int count, boolean useCache) { + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance, boolean chancePerSlot, int count) { + this(name, slots, frames, chance, chancePerSlot, count, true); + } + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance, boolean chancePerSlot) { + this(name, slots, frames, chance, chancePerSlot, 0, true); + } + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance) { + this(name, slots, frames, chance, false, 0, true); + } + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames) { + this(name, slots, frames, 100, false, 0, true); + } + + public TemporaryItem { + slots = List.copyOf(slots); + frames = List.copyOf(frames); + count = Math.max(0, count); + } + + /** + * Writes this item into its section of {@code temporary-items}. The keyframes are always written + * as {@code frames}, the general form {@code show} and {@code hide} are loaded into. + * + * @param section the section named after the item + */ + public void serialize(@NotNull ConfigurationSection section) { + section.set("slots", this.slots); + + ConfigurationSection framesSection = section.createSection("frames"); + for (int index = 0; index < this.frames.size(); index++) { + this.frames.get(index).serialize(framesSection.createSection(String.valueOf(index + 1))); + } + + if (this.chance != 100) section.set("chance", this.chance); + if (this.chancePerSlot) section.set("chance-per-slot", true); + if (this.count != 0) section.set("count", this.count); + if (!this.useCache) section.set("use-cache", false); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java b/API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java new file mode 100644 index 000000000..2422f41de --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java @@ -0,0 +1,17 @@ +package fr.maxlego08.menu.api.annotations; + +import fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a {@link SlotAnimationLoader} of zMenu that is + * registered at startup. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface AutoSlotAnimationLoader { +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/annotations/PaperOnly.java b/API/src/main/java/fr/maxlego08/menu/api/annotations/PaperOnly.java index 51913f3d6..64ce96323 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/annotations/PaperOnly.java +++ b/API/src/main/java/fr/maxlego08/menu/api/annotations/PaperOnly.java @@ -5,6 +5,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Marks a class that must only be loaded on Paper. + * + * @deprecated zMenu only runs on Paper and its forks, so every class already is. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface PaperOnly { diff --git a/API/src/main/java/fr/maxlego08/menu/api/annotations/SpigotOnly.java b/API/src/main/java/fr/maxlego08/menu/api/annotations/SpigotOnly.java index 986a8f687..9428549ff 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/annotations/SpigotOnly.java +++ b/API/src/main/java/fr/maxlego08/menu/api/annotations/SpigotOnly.java @@ -6,6 +6,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Marks a class that must only be loaded on Spigot; such a class is never loaded. + * + * @deprecated zMenu only runs on Paper and its forks. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface SpigotOnly { diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/Button.java b/API/src/main/java/fr/maxlego08/menu/api/button/Button.java index bb0c35e58..8b1cd0e31 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/button/Button.java +++ b/API/src/main/java/fr/maxlego08/menu/api/button/Button.java @@ -3,6 +3,8 @@ import fr.maxlego08.menu.api.Inventory; import fr.maxlego08.menu.api.MenuItemStack; import fr.maxlego08.menu.api.MenuPlugin; +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.idle.ButtonIdleAnimation; import fr.maxlego08.menu.api.configuration.Configuration; import fr.maxlego08.menu.api.engine.AnvilInventoryEngine; import fr.maxlego08.menu.api.engine.InventoryEngine; @@ -61,6 +63,8 @@ public abstract class Button extends PlaceholderButton { private RefreshRequirement refreshRequirement; private int priority; // only use for convert DeluxeMenus config to zmenu object private boolean isInPlayerInventory; + private ButtonAnimationSettings animationSettings = ButtonAnimationSettings.DEFAULT; + private ButtonIdleAnimation idleAnimation; @Contract(pure = true) @Nullable @@ -566,6 +570,66 @@ public void setRefreshRequirement(@Nullable RefreshRequirement refreshRequiremen this.refreshRequirement = refreshRequirement; } + /** + * Returns how this button takes part in the opening animation of its inventory. + * + * @return the animation settings, never null + */ + @Contract(pure = true) + @NotNull + public ButtonAnimationSettings getAnimationSettings() { + if (this.animationSettings == ButtonAnimationSettings.DEFAULT && !this.allowsTemporaryItems()) { + return ButtonAnimationSettings.NOT_COVERABLE; + } + return this.animationSettings; + } + + /** + * Returns the animation this button plays on its own slots for as long as the menu stays open. + * + * @return the idle animation, or null if it has none + */ + @Contract(pure = true) + @Nullable + public ButtonIdleAnimation getIdleAnimation() { + return this.idleAnimation; + } + + /** + * Sets the animation this button plays on its own slots for as long as the menu stays open. + * + * @param idleAnimation the idle animation, or null for none + */ + public void setIdleAnimation(@Nullable ButtonIdleAnimation idleAnimation) { + this.idleAnimation = idleAnimation; + } + + /** + * Returns whether a temporary item of an animation may be drawn over the slots of this button. + * + *

Buttons refuse by default: an animation drawing over a button hides it, and on a button + * holding something a player can take it could hand out an item that was only ever decoration. + * A button that is safe to cover, or that a placeholder is useful on, overrides this.

+ * + *

This is the default the configuration starts from: a file may still set + * {@code animation.allow-temporary-items} either way for a button of its own.

+ * + * @return true if a temporary item may cover this button + */ + @Contract(pure = true) + public boolean allowsTemporaryItems() { + return false; + } + + /** + * Sets how this button takes part in the opening animation of its inventory. + * + * @param animationSettings the animation settings, null resets it to the default + */ + public void setAnimationSettings(@Nullable ButtonAnimationSettings animationSettings) { + this.animationSettings = animationSettings == null ? ButtonAnimationSettings.DEFAULT : animationSettings; + } + @Contract(pure = true) public int getPriority() { return this.priority; diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/ButtonOptionType.java b/API/src/main/java/fr/maxlego08/menu/api/button/ButtonOptionType.java new file mode 100644 index 000000000..b6e67fb65 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/button/ButtonOptionType.java @@ -0,0 +1,29 @@ +package fr.maxlego08.menu.api.button; + +import fr.maxlego08.menu.api.ZMenuKeys; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +/** + * A {@link ButtonOption} registered in {@link fr.maxlego08.menu.api.registry.Registries#BUTTON_OPTION}. + * Every button gets a new instance of every registered option. + * + * @param key the key of the option, {@code :} for {@link #of(Plugin, Class)} + * @param plugin the plugin the option belongs to, passed to its constructor + * @param optionClass the class of the option, created with a {@code (Plugin)} or a no-argument constructor + */ +public record ButtonOptionType(@NotNull Key key, @NotNull Plugin plugin, @NotNull Class optionClass) implements Keyed { + + /** + * Creates the type of an option, keyed by its plugin and the simple name of its class. + * + * @param plugin the plugin the option belongs to + * @param optionClass the class of the option + * @return the type + */ + public static @NotNull ButtonOptionType of(@NotNull Plugin plugin, @NotNull Class optionClass) { + return new ButtonOptionType(ZMenuKeys.fileKey(plugin.getName(), optionClass.getSimpleName()), plugin, optionClass); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/PerformButton.java b/API/src/main/java/fr/maxlego08/menu/api/button/PerformButton.java index a4859ce66..91ea97937 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/button/PerformButton.java +++ b/API/src/main/java/fr/maxlego08/menu/api/button/PerformButton.java @@ -1,12 +1,14 @@ package fr.maxlego08.menu.api.button; import com.tcoded.folialib.impl.PlatformScheduler; +import com.tcoded.folialib.wrapper.task.WrappedTask; import fr.maxlego08.menu.api.MenuPlugin; import fr.maxlego08.menu.api.configuration.Configuration; import fr.maxlego08.menu.api.utils.Placeholders; import fr.maxlego08.menu.zcore.logger.Logger; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import org.jetbrains.annotations.Contract; @@ -15,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; public abstract class PerformButton extends SlotButton { @@ -171,11 +174,12 @@ private void execute(@NotNull MenuPlugin plugin, @NotNull Player player, @NotNul player.chat("/" + plugin.parse(player, command)); } else { String finalCommand = command; - Runnable runnable = () -> Bukkit.dispatchCommand(executor, plugin.parse(player, finalCommand)); - if (plugin.isFolia()) { - if (executor instanceof Player) scheduler.runAtEntity(((Player) executor), w -> runnable.run()); - else scheduler.runNextTick(w -> runnable.run()); - } else runnable.run(); + Consumer task = w -> Bukkit.dispatchCommand(executor, plugin.parse(player, finalCommand)); + if (executor instanceof Entity entity) { + scheduler.runAtEntity(entity, task); + } else { + scheduler.runNextTick(task); + } } } catch (Exception exception) { if (Configuration.enableDebug){ diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java b/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java index cede50b54..faa7f6f1c 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java +++ b/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java @@ -4,5 +4,18 @@ public abstract class ItemDragButton extends Button { + /** + * A drag slot is exactly where a placeholder is useful: it shows the player where an item is + * meant to go while the menu is still opening. + * + *

The items an animation draws are marked as belonging to the menu, so closing the inventory + * in the middle of one never hands them to the player.

+ * + * @return true + */ + @Override + public boolean allowsTemporaryItems() { + return true; + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/buttons/NoneButton.java b/API/src/main/java/fr/maxlego08/menu/api/button/buttons/NoneButton.java index db6ee01b8..981caf5da 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/button/buttons/NoneButton.java +++ b/API/src/main/java/fr/maxlego08/menu/api/button/buttons/NoneButton.java @@ -18,7 +18,10 @@ * the default interaction patterns established by the plugin's UI framework. Implementations might * include basic navigation, confirmation actions, or triggers for non-complex operations. *

+ * + * @deprecated adds nothing to {@link Button}, and zMenu's own default button does not extend it; extend {@link Button}. */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public abstract class NoneButton extends Button { } diff --git a/API/src/main/java/fr/maxlego08/menu/api/checker/InventoryLoadRequirement.java b/API/src/main/java/fr/maxlego08/menu/api/checker/InventoryLoadRequirement.java index d557156c9..377ae6967 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/checker/InventoryLoadRequirement.java +++ b/API/src/main/java/fr/maxlego08/menu/api/checker/InventoryLoadRequirement.java @@ -56,7 +56,16 @@ public void addRequirement(@NotNull InventoryRequirementType inventoryRequiremen } public void removeRequirement(@NotNull InventoryRequirementType inventoryRequirementType, @NotNull String name) { - this.requirements.get(inventoryRequirementType).removeIf(e -> e.equalsIgnoreCase(name)); + String loaded = normalize(name); + this.requirements.get(inventoryRequirementType).removeIf(e -> normalize(e).equals(loaded)); + } + + /** + * Reads a name the way registries read keys, so that a menu waiting for {@code play animation} + * loads once {@code play_animation} is registered. + */ + private static String normalize(String name) { + return name.trim().toLowerCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); } @Contract(pure = true) diff --git a/API/src/main/java/fr/maxlego08/menu/api/command/CommandArgumentValidator.java b/API/src/main/java/fr/maxlego08/menu/api/command/CommandArgumentValidator.java index 8d5325915..73c9e2763 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/command/CommandArgumentValidator.java +++ b/API/src/main/java/fr/maxlego08/menu/api/command/CommandArgumentValidator.java @@ -1,8 +1,12 @@ package fr.maxlego08.menu.api.command; +import fr.maxlego08.menu.api.ZMenuKeys; import fr.maxlego08.menu.api.utils.Message; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; -public interface CommandArgumentValidator { +public interface CommandArgumentValidator extends Keyed { /** * Checks if the given value is valid for this validator. * @@ -25,4 +29,15 @@ public interface CommandArgumentValidator { * @return The type of this argument validator. */ String getType(); + + /** + * The key of this validator in {@link fr.maxlego08.menu.api.registry.Registries#COMMAND_ARGUMENT_VALIDATOR}, + * read from {@link #getType()} like a {@code type} written in a command file. + * + * @return the key + */ + @Override + default @NotNull Key key() { + return ZMenuKeys.parse(this.getType()); + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/command/CommandManager.java b/API/src/main/java/fr/maxlego08/menu/api/command/CommandManager.java index a50e80cfc..bec53b97f 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/command/CommandManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/command/CommandManager.java @@ -115,11 +115,37 @@ public interface CommandManager { */ boolean reload(Command command); + /** + * Registers an argument type for custom commands. + * + * @param commandArgumentValidator the validator + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#COMMAND_ARGUMENT_VALIDATOR}: + * {@code registries.writable(Registries.COMMAND_ARGUMENT_VALIDATOR).register(plugin, validator)}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void registerArgumentValidator(CommandArgumentValidator commandArgumentValidator); + /** + * Removes an argument type. + * + * @param commandArgumentValidator the validator + * @deprecated use {@code registries.writable(Registries.COMMAND_ARGUMENT_VALIDATOR).unregister(validator.key())}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void unregisterArgumentValidator(CommandArgumentValidator commandArgumentValidator); + /** + * @return every argument type + * @deprecated read {@link fr.maxlego08.menu.api.registry.Registries#COMMAND_ARGUMENT_VALIDATOR}. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) List getArgumentValidators(); + /** + * Finds an argument type by the {@code type} written in a command file. + * + * @param argumentTypeName the type + * @return the validator, if one has this type + */ Optional getArgumentValidator(String argumentTypeName); } diff --git a/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java b/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java index 4a576b456..e3ef80113 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java +++ b/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java @@ -16,8 +16,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.EnumSet; import java.util.List; import java.util.Locale; +import java.util.Set; public class Configuration { @@ -78,7 +80,7 @@ public class Configuration { ) public static boolean enableOpenMessage = true; - // Enable mini message format, allows you to activate the mini message format, available from 1.17 onwards, more information here: https://docs.advntr.dev/minimessage/index.html + // Enables Dialogs support. MiniMessage formatting is always enabled, more information here: https://docs.advntr.dev/minimessage/index.html @ConfigOption( type = DialogInputType.BOOLEAN, trueText = "Enabled", @@ -105,6 +107,24 @@ public class Configuration { ) public static boolean enableFastEvent = false; + @ConfigOption( + type = DialogInputType.NUMBER_RANGE, + label = "Max animation duration in milliseconds", + startRange = 1000, + endRange = 60000, + stepRange = 500 + ) + public static int maxAnimationDuration = 10000; + + @ConfigOption( + type = DialogInputType.NUMBER_RANGE, + label = "Idle buttons before sharing one timer", + startRange = 0, + endRange = 54, + stepRange = 1 + ) + public static int sharedIdleTimerThreshold = 8; + // Seconds save player data: The time in seconds for automatic backup of player data. @ConfigOption( type = DialogInputType.NUMBER_RANGE, @@ -471,6 +491,9 @@ public class Configuration { ) public static long performanceThresholdMs = 10; + private static final String FEATURES_SECTION = "features"; + private static final Set enabledFeatures = defaultFeatures(); + @ConfigUpdate public static boolean updated = false; /** @@ -499,8 +522,35 @@ public static Configuration getInstance() { return instance; } + /** + * Tells whether {@code config.yml} enables a feature, as of the last {@link #load(FileConfiguration)}. + * zMenu reads features once at startup, use {@link fr.maxlego08.menu.api.MenuPlugin#isFeatureEnabled(Feature)} + * to know what is actually running. + * + * @param feature the feature + * @return whether the configuration enables it + */ + public static boolean isEnabled(@NotNull Feature feature) { + return enabledFeatures.contains(feature); + } + + private static Set defaultFeatures() { + Set features = EnumSet.noneOf(Feature.class); + for (Feature feature : Feature.values()) { + if (feature.isEnabledByDefault()) features.add(feature); + } + return features; + } + public void load(@NotNull FileConfiguration fileConfiguration) { + enabledFeatures.clear(); + for (Feature feature : Feature.values()) { + if (fileConfiguration.getBoolean(FEATURES_SECTION + "." + feature.getPath(), feature.isEnabledByDefault())) { + enabledFeatures.add(feature); + } + } + enableDebug = fileConfiguration.getBoolean(ConfigPath.ENABLE_DEBUG.getPath()); enableDebugTime = fileConfiguration.getBoolean(ConfigPath.ENABLE_DEBUG_TIME.getPath()); enableInformationMessage = fileConfiguration.getBoolean(ConfigPath.ENABLE_INFORMATION_MESSAGE.getPath()); @@ -541,6 +591,8 @@ public void load(@NotNull FileConfiguration fileConfiguration) { allClicksType = clickTypes; enableCacheItemStack = fileConfiguration.getBoolean(ConfigPath.ENABLE_CACHE_ITEM_STACK.getPath()); + maxAnimationDuration = fileConfiguration.getInt(ConfigPath.MAX_ANIMATION_DURATION.getPath(), 10000); + sharedIdleTimerThreshold = Math.max(0, fileConfiguration.getInt(ConfigPath.SHARED_IDLE_TIMER_THRESHOLD.getPath(), 8)); // Both read with an explicit default: the one-arg getters return false / 0 for a missing key, // which would silently disable the click throttle on a config that predates these options. enableCooldownClick = fileConfiguration.getBoolean(ConfigPath.ENABLE_COOLDOWN_CLICK.getPath(), true); @@ -635,6 +687,8 @@ public void save(@NotNull FileConfiguration fileConfiguration, @NotNull File fil fileConfiguration.set(ConfigPath.ALL_CLICKS_TYPE.getPath(), clickTypeNames); fileConfiguration.set(ConfigPath.ENABLE_CACHE_ITEM_STACK.getPath(), enableCacheItemStack); + fileConfiguration.set(ConfigPath.MAX_ANIMATION_DURATION.getPath(), maxAnimationDuration); + fileConfiguration.set(ConfigPath.SHARED_IDLE_TIMER_THRESHOLD.getPath(), sharedIdleTimerThreshold); fileConfiguration.set(ConfigPath.ENABLE_COOLDOWN_CLICK.getPath(), enableCooldownClick); fileConfiguration.set(ConfigPath.COOLDOWN_CLICK_MILLISECONDS.getPath(), cooldownClickMilliseconds); fileConfiguration.set(ConfigPath.CACHE_PLACEHOLDER_API.getPath(), cachePlaceholderAPI); @@ -703,6 +757,8 @@ private enum ConfigPath { ALL_CLICKS_TYPE("all-clicks-type"), ENABLE_CACHE_ITEM_STACK("enable-cache-item-stack"), + MAX_ANIMATION_DURATION("max-animation-duration"), + SHARED_IDLE_TIMER_THRESHOLD("shared-idle-timer-threshold"), ENABLE_COOLDOWN_CLICK("enable-cooldown-click"), COOLDOWN_CLICK_MILLISECONDS("cooldown-click-milliseconds"), diff --git a/API/src/main/java/fr/maxlego08/menu/api/configuration/Feature.java b/API/src/main/java/fr/maxlego08/menu/api/configuration/Feature.java new file mode 100644 index 000000000..66ffe86e2 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/configuration/Feature.java @@ -0,0 +1,45 @@ +package fr.maxlego08.menu.api.configuration; + +import org.jetbrains.annotations.NotNull; + +/** + * An optional part of zMenu that a server can turn off in the {@code features} section of + * {@code config.yml}. A disabled feature registers nothing: no listener, command, registry entry or + * default file. + *

+ * The state is read once when zMenu enables; changing it needs a server restart. + * + * @see fr.maxlego08.menu.api.MenuPlugin#isFeatureEnabled(Feature) + */ +public enum Feature { + + /** + * Custom items from the {@code items} folder, their mechanics and the {@code /zm giveitem} command. + * When disabled, {@link fr.maxlego08.menu.api.MenuPlugin#getItemManager()} returns a manager that + * knows no item and ignores every registration. + */ + CUSTOM_ITEMS("custom-items", true); + + private final String path; + private final boolean enabledByDefault; + + Feature(String path, boolean enabledByDefault) { + this.path = path; + this.enabledByDefault = enabledByDefault; + } + + /** + * @return the key of this feature in the {@code features} section of {@code config.yml} + */ + @NotNull + public String getPath() { + return this.path; + } + + /** + * @return whether this feature is enabled when {@code config.yml} does not mention it + */ + public boolean isEnabledByDefault() { + return this.enabledByDefault; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java b/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java index c796081d2..9404582d3 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java @@ -27,6 +27,16 @@ public interface BaseInventory extends InventoryHolder { @Contract(pure = true) boolean isClose(); + /** + * Returns whether this inventory currently ignores every click, for instance while its opening + * animation plays. + * + * @return true if the inventory is frozen + */ + default boolean isFrozen() { + return false; + } + @Contract("_, null -> null; _, !null -> !null") @Nullable ItemButton addItem(int slot, @Nullable ItemStack itemStack); @@ -47,9 +57,18 @@ public interface BaseInventory extends InventoryHolder { @Nullable String getGuiName(); + /** + * Gets the Bukkit inventory shown to the player. + * + * @return the inventory + * @deprecated returns the same inventory as {@link #getInventory()}. + */ @Contract(pure = true) @NotNull - Inventory getSpigotInventory(); + @Deprecated(since = "1.1.2.1", forRemoval = true) + default Inventory getSpigotInventory() { + return this.getInventory(); + } @Contract(pure = true) @NotNull Inventory getInventory(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java b/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java index cb57ba27f..3d52f016a 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java @@ -1,6 +1,8 @@ package fr.maxlego08.menu.api.engine; import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; import fr.maxlego08.menu.api.button.Button; import fr.maxlego08.menu.api.utils.Placeholders; import fr.maxlego08.menu.zcore.utils.PerformanceDebug; @@ -59,9 +61,71 @@ public interface InventoryEngine extends BaseInventory { @Contract(pure = true) int getMaxPage(); - @Contract(pure = true) + /** + * Sets the number of pages of this inventory. + * + * @param maxPage the number of pages + * @deprecated computed by zMenu from the buttons and patterns when the inventory opens. + */ + @Deprecated(since = "1.1.2.1", forRemoval = true) void setMaxPage(int maxPage); - @Contract(pure = true) void cancel(int slot); + + /** + * Plays an animation on this inventory, now. + * + *

{@link AnimationPlayback#OVERLAY} draws the temporary items of the animation over the menu + * without hiding anything, which is what an already open inventory usually wants; + * {@link AnimationPlayback#REVEAL} hides the buttons and reveals them again, as opening the menu + * does. An overlay does not freeze the inventory, a reveal freezes it if the animation says so.

+ * + *

Only one animation plays at a time: starting another finishes the one in progress at once + * rather than queueing behind it.

+ * + * @param animation the animation to play + * @param playback how to play it + * @return how long the animation lasts, in milliseconds, or 0 if nothing was played + */ + default long playAnimation(@NotNull Animation animation, @NotNull AnimationPlayback playback) { + return 0; + } + + /** + * Plays an animation on this inventory, deciding whether it freezes the inventory. + * + * @param animation the animation to play + * @param playback how to play it + * @param freeze whether the inventory ignores clicks until the animation is over + * @return how long the animation lasts, in milliseconds, or 0 if nothing was played + */ + default long playAnimation(@NotNull Animation animation, @NotNull AnimationPlayback playback, boolean freeze) { + return 0; + } + + /** + * Finishes the animation currently playing, applying everything that is left at once. + */ + default void stopAnimation() { + } + + /** + * Runs something once the closing animation of this inventory is over. + * + *

This is how a menu closes itself gracefully. The window is not closed while the animation + * plays, because it cannot be: a client that has closed its inventory cannot be told to wait. + * Nothing has closed it yet, so the menu simply stays open until the buttons have been taken + * away, and only then does {@code whenDone} run.

+ * + *

It is only for a close the menu decides on, such as a {@code close} action or a switch to + * another menu. A player pressing escape closes the window immediately, and nothing can hold + * it open, so {@code whenDone} runs at once when there is no closing animation to play, when + * the inventory is already closed, or when the animation refuses to play.

+ * + * @param whenDone what to run once the menu is ready to go away. It runs at most once, and is + * skipped when the player closed the window themselves while it played + */ + default void closeWithAnimation(@NotNull Runnable whenDone) { + whenDone.run(); + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/ItemButton.java b/API/src/main/java/fr/maxlego08/menu/api/engine/ItemButton.java index 12541cfc6..7a0a293ed 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/engine/ItemButton.java +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/ItemButton.java @@ -89,7 +89,7 @@ public void updateDisplayItem(@NotNull ItemStack displayItem) { if (this.inPlayerInventory) { this.baseInventory.getPlayerInventoryItems().put(this.slot, this); } else { - this.baseInventory.getSpigotInventory().setItem(this.slot, displayItem); + this.baseInventory.getInventory().setItem(this.slot, displayItem); } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java b/API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java new file mode 100644 index 000000000..2b0c08bbe --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java @@ -0,0 +1,21 @@ +package fr.maxlego08.menu.api.engine; + +/** + * Why an inventory is being opened. + * + *

Passed to the engine among the opening arguments, so that it can tell a genuine open from a + * menu redrawing itself. Anything that re-opens the inventory a player is already looking at, such + * as a page change or a {@code refresh inventory} action, is a {@link #REFRESH}.

+ */ +public enum OpenCause { + + /** + * The player is opening the inventory, from closed or from another menu. + */ + OPEN, + + /** + * The inventory the player is already looking at is being redrawn. + */ + REFRESH +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/event/MenuEvent.java b/API/src/main/java/fr/maxlego08/menu/api/event/MenuEvent.java index 4bac75723..d2a606254 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/event/MenuEvent.java +++ b/API/src/main/java/fr/maxlego08/menu/api/event/MenuEvent.java @@ -15,7 +15,6 @@ public class MenuEvent extends Event { */ public MenuEvent() { super(); - // TODO Auto-generated constructor stub } /** @@ -23,7 +22,6 @@ public MenuEvent() { */ public MenuEvent(boolean isAsync) { super(isAsync); - // TODO Auto-generated constructor stub } public static HandlerList getHandlerList() { diff --git a/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java new file mode 100644 index 000000000..89923bb33 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java @@ -0,0 +1,65 @@ +package fr.maxlego08.menu.api.event.events; + +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.engine.InventoryEngine; +import fr.maxlego08.menu.api.event.MenuEvent; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +/** + * Called once an animation is over, whether it played to its end or was finished early because + * another one started, the inventory closed or the server stopped. + */ +public class AnimationEndEvent extends MenuEvent { + + private final Player player; + private final InventoryEngine inventory; + private final Animation animation; + private final AnimationPlayback playback; + private final boolean completed; + + public AnimationEndEvent(@NotNull Player player, @NotNull InventoryEngine inventory, + @NotNull Animation animation, @NotNull AnimationPlayback playback, boolean completed) { + this.player = player; + this.inventory = inventory; + this.animation = animation; + this.playback = playback; + this.completed = completed; + } + + @Contract(pure = true) + @NotNull + public Player getPlayer() { + return this.player; + } + + @Contract(pure = true) + @NotNull + public InventoryEngine getInventory() { + return this.inventory; + } + + @Contract(pure = true) + @NotNull + public Animation getAnimation() { + return this.animation; + } + + @Contract(pure = true) + @NotNull + public AnimationPlayback getPlayback() { + return this.playback; + } + + /** + * Returns whether every step was played in time, rather than the rest being applied at once. + * + * @return true if the animation reached its end on its own + */ + @Contract(pure = true) + public boolean isCompleted() { + return this.completed; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java new file mode 100644 index 000000000..4ee3b29b6 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java @@ -0,0 +1,74 @@ +package fr.maxlego08.menu.api.event.events; + +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.engine.InventoryEngine; +import fr.maxlego08.menu.api.event.CancelledMenuEvent; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +/** + * Called before an animation starts playing on an inventory. + * + *

Cancelling it stops the animation from playing at all; the inventory is displayed complete, as + * if it had none.

+ */ +public class AnimationStartEvent extends CancelledMenuEvent { + + private final Player player; + private final InventoryEngine inventory; + private final Animation animation; + private final AnimationPlayback playback; + private boolean freeze; + + public AnimationStartEvent(@NotNull Player player, @NotNull InventoryEngine inventory, + @NotNull Animation animation, @NotNull AnimationPlayback playback, boolean freeze) { + this.player = player; + this.inventory = inventory; + this.animation = animation; + this.playback = playback; + this.freeze = freeze; + } + + @Contract(pure = true) + @NotNull + public Player getPlayer() { + return this.player; + } + + @Contract(pure = true) + @NotNull + public InventoryEngine getInventory() { + return this.inventory; + } + + @Contract(pure = true) + @NotNull + public Animation getAnimation() { + return this.animation; + } + + @Contract(pure = true) + @NotNull + public AnimationPlayback getPlayback() { + return this.playback; + } + + /** + * @return whether the inventory will ignore clicks while the animation plays + */ + @Contract(pure = true) + public boolean isFreeze() { + return this.freeze; + } + + /** + * Changes whether the inventory ignores clicks while the animation plays. + * + * @param freeze true to freeze it + */ + public void setFreeze(boolean freeze) { + this.freeze = freeze; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/event/events/ButtonLoaderRegisterEvent.java b/API/src/main/java/fr/maxlego08/menu/api/event/events/ButtonLoaderRegisterEvent.java index 996e783cd..55b141ebf 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/event/events/ButtonLoaderRegisterEvent.java +++ b/API/src/main/java/fr/maxlego08/menu/api/event/events/ButtonLoaderRegisterEvent.java @@ -49,7 +49,11 @@ public ButtonManager getButtonManager() { * Register a ButtonLoader * * @param button the button to register + * @deprecated register it in {@link fr.maxlego08.menu.api.registry.Registries#BUTTON} from your + * {@code onEnable}; this event is no longer needed to register button types. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) + @SuppressWarnings("removal") public void register(@NotNull ButtonLoader button) { this.buttonManager.register(button); } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ButtonAlreadyRegisterException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ButtonAlreadyRegisterException.java index 7d902d95d..2372c944e 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ButtonAlreadyRegisterException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ButtonAlreadyRegisterException.java @@ -2,6 +2,12 @@ import java.io.Serial; +/** + * @deprecated no longer thrown: a button type registered twice is refused by + * {@link fr.maxlego08.menu.api.registry.Registries#BUTTON} with a + * {@link fr.maxlego08.menu.api.registry.RegistrationException}. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public class ButtonAlreadyRegisterException extends Error { /** @@ -15,7 +21,6 @@ public class ButtonAlreadyRegisterException extends Error { */ public ButtonAlreadyRegisterException() { super(); - // TODO Auto-generated constructor stub } /** @@ -27,7 +32,6 @@ public ButtonAlreadyRegisterException() { public ButtonAlreadyRegisterException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } /** @@ -36,7 +40,6 @@ public ButtonAlreadyRegisterException(String message, Throwable cause, boolean e */ public ButtonAlreadyRegisterException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } /** @@ -44,7 +47,6 @@ public ButtonAlreadyRegisterException(String message, Throwable cause) { */ public ButtonAlreadyRegisterException(String message) { super(message); - // TODO Auto-generated constructor stub } /** @@ -52,7 +54,6 @@ public ButtonAlreadyRegisterException(String message) { */ public ButtonAlreadyRegisterException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryAlreadyExistException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryAlreadyExistException.java index f7933844d..9da385762 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryAlreadyExistException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryAlreadyExistException.java @@ -12,28 +12,23 @@ public class InventoryAlreadyExistException extends Error { public InventoryAlreadyExistException() { super(); - // TODO Auto-generated constructor stub } public InventoryAlreadyExistException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } public InventoryAlreadyExistException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } public InventoryAlreadyExistException(String message) { super(message); - // TODO Auto-generated constructor stub } public InventoryAlreadyExistException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryButtonException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryButtonException.java index 9918d622f..4b9998bce 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryButtonException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryButtonException.java @@ -15,7 +15,6 @@ public class InventoryButtonException extends InventoryException { */ public InventoryButtonException() { super(); - // TODO Auto-generated constructor stub } /** @@ -27,7 +26,6 @@ public InventoryButtonException() { public InventoryButtonException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } /** @@ -36,7 +34,6 @@ public InventoryButtonException(String message, Throwable cause, boolean enableS */ public InventoryButtonException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } /** @@ -44,7 +41,6 @@ public InventoryButtonException(String message, Throwable cause) { */ public InventoryButtonException(String message) { super(message); - // TODO Auto-generated constructor stub } /** @@ -52,7 +48,6 @@ public InventoryButtonException(String message) { */ public InventoryButtonException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryException.java index 2950c8e88..a852286db 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryException.java @@ -11,27 +11,22 @@ public class InventoryException extends Exception { private static final long serialVersionUID = 2498115281127927055L; public InventoryException() { - // TODO Auto-generated constructor stub } public InventoryException(String message) { super(message); - // TODO Auto-generated constructor stub } public InventoryException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } public InventoryException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } public InventoryException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryFileNotFound.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryFileNotFound.java index 6abb2d886..f40e3bd51 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryFileNotFound.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryFileNotFound.java @@ -15,7 +15,6 @@ public class InventoryFileNotFound extends InventoryException { */ public InventoryFileNotFound() { super(); - // TODO Auto-generated constructor stub } /** @@ -27,7 +26,6 @@ public InventoryFileNotFound() { public InventoryFileNotFound(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } /** @@ -36,7 +34,6 @@ public InventoryFileNotFound(String message, Throwable cause, boolean enableSupp */ public InventoryFileNotFound(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } /** @@ -44,7 +41,6 @@ public InventoryFileNotFound(String message, Throwable cause) { */ public InventoryFileNotFound(String message) { super(message); - // TODO Auto-generated constructor stub } /** @@ -52,7 +48,6 @@ public InventoryFileNotFound(String message) { */ public InventoryFileNotFound(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryOpenException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryOpenException.java index 1462ddedc..f92b57aef 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryOpenException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryOpenException.java @@ -10,28 +10,23 @@ public class InventoryOpenException extends Exception { public InventoryOpenException() { super(); - // TODO Auto-generated constructor stub } public InventoryOpenException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } public InventoryOpenException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } public InventoryOpenException(String message) { super(message); - // TODO Auto-generated constructor stub } public InventoryOpenException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventorySizeException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventorySizeException.java index d06a69aec..8184cf848 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventorySizeException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventorySizeException.java @@ -15,7 +15,6 @@ public class InventorySizeException extends InventoryException { */ public InventorySizeException() { super(); - // TODO Auto-generated constructor stub } /** @@ -27,7 +26,6 @@ public InventorySizeException() { public InventorySizeException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } /** @@ -36,7 +34,6 @@ public InventorySizeException(String message, Throwable cause, boolean enableSup */ public InventorySizeException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } /** @@ -44,7 +41,6 @@ public InventorySizeException(String message, Throwable cause) { */ public InventorySizeException(String message) { super(message); - // TODO Auto-generated constructor stub } /** @@ -52,7 +48,6 @@ public InventorySizeException(String message) { */ public InventorySizeException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryTypeException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryTypeException.java index bfb2a98f7..96b1795fc 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryTypeException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/InventoryTypeException.java @@ -15,7 +15,6 @@ public class InventoryTypeException extends InventoryException { */ public InventoryTypeException() { super(); - // TODO Auto-generated constructor stub } /** @@ -27,7 +26,6 @@ public InventoryTypeException() { public InventoryTypeException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } /** @@ -36,7 +34,6 @@ public InventoryTypeException(String message, Throwable cause, boolean enableSup */ public InventoryTypeException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } /** @@ -44,7 +41,6 @@ public InventoryTypeException(String message, Throwable cause) { */ public InventoryTypeException(String message) { super(message); - // TODO Auto-generated constructor stub } /** @@ -52,7 +48,6 @@ public InventoryTypeException(String message) { */ public InventoryTypeException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemComponentAlreadyRegisterException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemComponentAlreadyRegisterException.java index 0203f7338..dd2994b3a 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemComponentAlreadyRegisterException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemComponentAlreadyRegisterException.java @@ -1,5 +1,11 @@ package fr.maxlego08.menu.api.exceptions; +/** + * @deprecated no longer thrown: an item component registered twice is refused by + * {@link fr.maxlego08.menu.api.registry.Registries#ITEM_COMPONENT} with a + * {@link fr.maxlego08.menu.api.registry.RegistrationException}. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public class ItemComponentAlreadyRegisterException extends RuntimeException { public ItemComponentAlreadyRegisterException(String message) { super(message); diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemCreateException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemCreateException.java index 34cc614f7..f4c355084 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemCreateException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemCreateException.java @@ -2,6 +2,10 @@ import java.io.Serial; +/** + * @deprecated never thrown by zMenu. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public class ItemCreateException extends Error { /** @@ -12,27 +16,22 @@ public class ItemCreateException extends Error { public ItemCreateException() { super(); - // TODO Auto-generated constructor stub } public ItemCreateException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } public ItemCreateException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } public ItemCreateException(String message) { super(message); - // TODO Auto-generated constructor stub } public ItemCreateException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemFlagException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemFlagException.java index 90c03c78e..f445aff25 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemFlagException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ItemFlagException.java @@ -11,27 +11,22 @@ public class ItemFlagException extends Exception { private static final long serialVersionUID = 1L; public ItemFlagException() { - // TODO Auto-generated constructor stub } public ItemFlagException(String message) { super(message); - // TODO Auto-generated constructor stub } public ItemFlagException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } public ItemFlagException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } public ItemFlagException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ListenerNullException.java b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ListenerNullException.java index 089e58f22..f9d3e3231 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/exceptions/ListenerNullException.java +++ b/API/src/main/java/fr/maxlego08/menu/api/exceptions/ListenerNullException.java @@ -11,28 +11,23 @@ public class ListenerNullException extends Error { private static final long serialVersionUID = 1L; public ListenerNullException() { - // TODO Auto-generated constructor stub } public ListenerNullException(String message) { super(message); - // TODO Auto-generated constructor stub } public ListenerNullException(Throwable cause) { super(cause); - // TODO Auto-generated constructor stub } public ListenerNullException(String message, Throwable cause) { super(message, cause); - // TODO Auto-generated constructor stub } public ListenerNullException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); - // TODO Auto-generated constructor stub } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/interfaces/ItemComponentLoaderInterface.java b/API/src/main/java/fr/maxlego08/menu/api/interfaces/ItemComponentLoaderInterface.java index b497b8a42..ea5960a8e 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/interfaces/ItemComponentLoaderInterface.java +++ b/API/src/main/java/fr/maxlego08/menu/api/interfaces/ItemComponentLoaderInterface.java @@ -11,7 +11,10 @@ /** * Interface for classes that load custom item components from configuration. * Used to decouple loading implementations from main logic. + * + * @deprecated not used by zMenu; item components are loaded by {@link fr.maxlego08.menu.api.loader.ItemComponentLoader}. */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public interface ItemComponentLoaderInterface { @Nullable /** diff --git a/API/src/main/java/fr/maxlego08/menu/api/interfaces/ReturnConsumer.java b/API/src/main/java/fr/maxlego08/menu/api/interfaces/ReturnConsumer.java index 90f7501d0..f469c19ee 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/interfaces/ReturnConsumer.java +++ b/API/src/main/java/fr/maxlego08/menu/api/interfaces/ReturnConsumer.java @@ -1,5 +1,9 @@ package fr.maxlego08.menu.api.interfaces; +/** + * @deprecated use {@link java.util.function.Function}. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) @FunctionalInterface public interface ReturnConsumer { G accept(T var1); diff --git a/API/src/main/java/fr/maxlego08/menu/api/interfaces/StringConsumer.java b/API/src/main/java/fr/maxlego08/menu/api/interfaces/StringConsumer.java index 038eacc29..204f9072f 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/interfaces/StringConsumer.java +++ b/API/src/main/java/fr/maxlego08/menu/api/interfaces/StringConsumer.java @@ -1,5 +1,9 @@ package fr.maxlego08.menu.api.interfaces; +/** + * @deprecated use {@link java.util.function.Function}{@code }. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) @FunctionalInterface public interface StringConsumer { diff --git a/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java b/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java index 363d822da..c94ef239e 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java @@ -3,6 +3,7 @@ import fr.maxlego08.menu.api.Inventory; import fr.maxlego08.menu.api.MenuItemStack; import fr.maxlego08.menu.api.animation.TitleAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; import fr.maxlego08.menu.api.utils.ClearInvType; import org.bukkit.event.inventory.InventoryType; import org.jetbrains.annotations.Nullable; @@ -28,6 +29,16 @@ default boolean clearInventory() { @Nullable MenuItemStack getFillItemStack(); + /** + * Whether the fill item may be served from its build cache, the {@code use-cache} option of + * the fill item, which works like the one of a button. + * + * @return true when the cache may be used, which is the default + */ + default boolean isFillItemUseCache() { + return true; + } + boolean shouldCancelItemPickup(); InventoryType getType(); @@ -35,4 +46,46 @@ default boolean clearInventory() { boolean isClickLimiterEnabled(); ClearInvType getClearInvType(); + + /** + * Gets the animation played when this inventory is opened. + * + * @return the animation settings, or null if the inventory is displayed at once + */ + @Nullable + default SlotAnimationSettings getSlotAnimation() { + return null; + } + + /** + * Gets the animation played when this inventory closes itself. + * + *

Only plays for a close the menu decides on, such as a {@code close} action or a switch to + * another menu. A player pressing escape closes the window at once and nothing can hold it + * open long enough to animate.

+ * + * @return the animation settings, or null if the inventory just goes away + */ + @Nullable + default SlotAnimationSettings getCloseAnimation() { + return null; + } + + /** + * Sets the animation played when this inventory is opened. An inventory that cannot animate + * ignores it. + * + * @param slotAnimation the animation settings, or null to display the inventory at once + */ + default void setSlotAnimation(@Nullable SlotAnimationSettings slotAnimation) { + } + + /** + * Sets the animation played when this inventory closes itself. An inventory that cannot animate + * ignores it. + * + * @param closeAnimation the animation settings, or null to close at once + */ + default void setCloseAnimation(@Nullable SlotAnimationSettings closeAnimation) { + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackService.java b/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackService.java new file mode 100644 index 000000000..5e6bf72e5 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackService.java @@ -0,0 +1,86 @@ +package fr.maxlego08.menu.api.itemstack; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.button.Button; +import fr.maxlego08.menu.api.utils.Placeholders; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.Map; +import java.util.Optional; + +/** + * Reads, converts and compares the items of zMenu. + * + * @see fr.maxlego08.menu.api.MenuPlugin#getItemStackService() + */ +public interface ItemStackService { + + /** + * Loads a menu item from a YAML configuration, with the global placeholders applied. + * + * @param configuration the configuration holding the item + * @param path the path of the item in the configuration, ending with a dot + * @param file the file the configuration was read from, named when an error is logged + * @return the menu item + */ + MenuItemStack load(YamlConfiguration configuration, String path, File file); + + /** + * Loads a menu item from a map of its settings, such as the item of an action. + * + * @param file the file the map was read from, named when an error is logged + * @param map the settings of the item + * @return the menu item + */ + MenuItemStack load(File file, Map map); + + /** + * Turns an item into a menu item, with item components for every setting the item changes. + * When a setting cannot be expressed, the whole item is kept as {@code base64:} so that nothing is lost. + * + * @param itemStack the item to convert + * @return the menu item, and the components that forced the fallback + */ + @NotNull + MenuItemStackConversion fromItemStack(@NotNull ItemStack itemStack); + + /** + * Writes an item as a base64 string. + * + * @param itemStack the item + * @return the item as base64 + */ + String serialize(ItemStack itemStack); + + /** + * Reads an item written by {@link #serialize(ItemStack)}. + * + * @param serializedItemStack the item as base64 + * @return the item + */ + ItemStack deserialize(String serializedItemStack); + + /** + * Gives a player head the skin of the player named by the {@code player-head} of a button. + * + * @param itemStack the head + * @param button the button, whose player head is read + * @param player the player the placeholders are parsed for + * @param placeholders the placeholders of the inventory + * @return the head + */ + ItemStack applyPlayerHead(ItemStack itemStack, Button button, Player player, Placeholders placeholders); + + /** + * Gets a way to compare items by its name as written in a file, whatever its case. Comparisons + * are registered in {@link fr.maxlego08.menu.api.registry.Registries#ITEM_VERIFICATION}. + * + * @param name the name of the comparison + * @return the comparison, if one has this name + */ + Optional getVerification(String name); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackSimilar.java b/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackSimilar.java index c56ec3f5d..5c5135d88 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackSimilar.java +++ b/API/src/main/java/fr/maxlego08/menu/api/itemstack/ItemStackSimilar.java @@ -1,5 +1,8 @@ package fr.maxlego08.menu.api.itemstack; +import fr.maxlego08.menu.api.ZMenuKeys; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; @@ -7,7 +10,7 @@ * The ItemStackSimilar interface defines methods for comparing ItemStacks in Minecraft. * It is used to determine if two ItemStacks are similar based on a specific implementation. */ -public interface ItemStackSimilar { +public interface ItemStackSimilar extends Keyed { /** * Retrieves the name of the ItemStack comparison implementation. @@ -18,6 +21,17 @@ public interface ItemStackSimilar { @NotNull String getName(); + /** + * The key of this comparison in {@link fr.maxlego08.menu.api.registry.Registries#ITEM_VERIFICATION}, + * read from {@link #getName()} like a name written in a file. + * + * @return the key + */ + @Override + default @NotNull Key key() { + return ZMenuKeys.parse(this.getName()); + } + /** * Compares two ItemStacks to determine if they are similar according to a defined rule. * This method is used to compare items in specific contexts, such as menus or inventories. diff --git a/API/src/main/java/fr/maxlego08/menu/api/itemstack/Potion.java b/API/src/main/java/fr/maxlego08/menu/api/itemstack/Potion.java index bceb26489..c0a758dda 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/itemstack/Potion.java +++ b/API/src/main/java/fr/maxlego08/menu/api/itemstack/Potion.java @@ -93,9 +93,10 @@ public Potion(@NotNull PotionType type, int level, boolean splash, boolean exten * * @param damage the damage value * @return the produced potion + * @deprecated potions have no damage value since Minecraft 1.9, and several of the type names it uses no longer exist. */ - @NotNull + @Deprecated(since = "1.1.2.1", forRemoval = true) public static Potion fromDamage(int damage) { PotionType type = switch (damage & POTION_BIT) { // case 0 -> PotionType.WATER; @@ -135,9 +136,11 @@ public static Potion fromDamage(int damage) { * Returns an instance of {@link PotionBrewer}. * * @return An instance of PotionBrewer + * @deprecated zMenu never sets a brewer, so this is always null. */ @Contract(pure = true) @Nullable + @Deprecated(since = "1.1.2.1", forRemoval = true) public static PotionBrewer getBrewer() { return brewer; } @@ -147,7 +150,9 @@ public static PotionBrewer getBrewer() { * used from within a plugin. * * @param other The new PotionBrewer + * @deprecated nothing reads the brewer. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) public static void setPotionBrewer(@Nullable PotionBrewer other) { if (brewer != null) throw new IllegalArgumentException("brewer can only be set internally"); @@ -369,8 +374,10 @@ public void setArrow(boolean isArrow) { * item stacks. * * @return The damage value of this potion Non-functional + * @deprecated always returns 0: potions have no damage value since Minecraft 1.9. */ @Contract(pure = true) + @Deprecated(since = "1.1.2.1", forRemoval = true) public short toDamageValue() { return 0; } @@ -403,9 +410,10 @@ else if (this.isSplash()) * Gets the potion from its name id. * * @return the name id Non-functional + * @deprecated always returns 0. */ - @Contract(pure = true) + @Deprecated(since = "1.1.2.1", forRemoval = true) public int getNameId() { return 0; } diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/ActionLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/ActionLoader.java index 62ce86bb0..9b2039f1b 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/loader/ActionLoader.java +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/ActionLoader.java @@ -1,7 +1,10 @@ package fr.maxlego08.menu.api.loader; +import fr.maxlego08.menu.api.ZMenuKeys; import fr.maxlego08.menu.api.requirement.Action; import fr.maxlego08.menu.api.utils.TypedMapAccessor; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,7 +15,7 @@ /** * A loader for creating instances of {@link Action} based on configuration. */ -public abstract class ActionLoader { +public abstract class ActionLoader implements Keyed { private final List keys; @@ -45,6 +48,26 @@ public List getKeys() { return this.keys; } + /** + * The key of this action in {@link fr.maxlego08.menu.api.registry.Registries#ACTION}: its first + * name, read like a {@code type} written in a file. + * + * @return the key + */ + @Override + public @NotNull Key key() { + return ZMenuKeys.parse(this.keys.getFirst()); + } + + /** + * The other names of this action, read like the key. + * + * @return the other names, without the key + */ + public @NotNull List aliases() { + return this.keys.stream().skip(1).map(ZMenuKeys::parse).toList(); + } + /** * Creates an instance of {@link Action} based on the provided configuration. * diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/ButtonLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/ButtonLoader.java index 55f3a60a7..75098a1a0 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/loader/ButtonLoader.java +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/ButtonLoader.java @@ -1,7 +1,10 @@ package fr.maxlego08.menu.api.loader; +import fr.maxlego08.menu.api.ZMenuKeys; import fr.maxlego08.menu.api.button.Button; import fr.maxlego08.menu.api.button.DefaultButtonValue; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Contract; @@ -27,7 +30,7 @@ * For example, if your plugin is called "test" and your button is called "magic", the name should be 'test_magic'. * You can then return a new instance of your button with the essential elements initialized. */ -public abstract class ButtonLoader { +public abstract class ButtonLoader implements Keyed { protected final Plugin plugin; protected final String name; @@ -99,6 +102,26 @@ public List getAliases() { return Collections.unmodifiableList(this.aliases); } + /** + * The key of this button type in {@link fr.maxlego08.menu.api.registry.Registries#BUTTON}, read + * from {@link #getName()} like a {@code type} written in a file. + * + * @return the key + */ + @Override + public @NotNull Key key() { + return ZMenuKeys.parse(this.name); + } + + /** + * {@link #getAliases()} read like the key. + * + * @return the other names, as keys + */ + public @NotNull List aliases() { + return this.aliases.stream().map(ZMenuKeys::parse).toList(); + } + /** * Gets the plugin from which the button loader originates. * diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/ItemComponentLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/ItemComponentLoader.java index 1b5145829..10704d18b 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/loader/ItemComponentLoader.java +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/ItemComponentLoader.java @@ -1,5 +1,6 @@ package fr.maxlego08.menu.api.loader; +import fr.maxlego08.menu.api.ZMenuKeys; import fr.maxlego08.menu.api.context.MenuItemStackContext; import fr.maxlego08.menu.api.itemstack.ItemComponent; import fr.maxlego08.menu.api.utils.PaperMetaUpdater; @@ -11,6 +12,8 @@ import io.papermc.paper.datacomponent.DataComponentType; import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.NamespacedKey; @@ -28,7 +31,7 @@ import java.util.function.Function; @SuppressWarnings("unused") -public abstract class ItemComponentLoader { +public abstract class ItemComponentLoader implements Keyed { public enum ComponentOwnerType { /** * Component provided by Minecraft itself. @@ -170,6 +173,27 @@ public String getComponentName() { return this.componentName; } + /** + * The key of this component in {@link fr.maxlego08.menu.api.registry.Registries#ITEM_COMPONENT}: + * {@code minecraft:} for a Minecraft component, {@code :} for a plugin's. + * + * @return the key + */ + @Override + public @NotNull Key key() { + String namespace = this.ownerType == ComponentOwnerType.PLUGIN && this.plugin != null ? this.plugin.getName() : "minecraft"; + return ZMenuKeys.parse(namespace + ":" + this.componentName); + } + + /** + * The other names of this component: a Minecraft component is also known without its namespace. + * + * @return the other names, as keys + */ + public @NotNull List aliases() { + return this.ownerType == ComponentOwnerType.MINECRAFT ? List.of(ZMenuKeys.parse(this.componentName)) : List.of(); + } + @NotNull public ComponentOwnerType getOwnerType() { return this.ownerType; diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/MaterialLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/MaterialLoader.java index fcfb52da3..48f60fb1f 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/loader/MaterialLoader.java +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/MaterialLoader.java @@ -1,5 +1,8 @@ package fr.maxlego08.menu.api.loader; +import fr.maxlego08.menu.api.ZMenuKeys; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -11,7 +14,7 @@ * Documentation: here *

The MaterialLoader interface defines methods for loading an {@link ItemStack} from a configuration.

*/ -public abstract class MaterialLoader { +public abstract class MaterialLoader implements Keyed { private final String key; @@ -30,6 +33,18 @@ public String getKey() { return this.key; } + /** + * The key of this loader in {@link fr.maxlego08.menu.api.registry.Registries#MATERIAL_LOADER}, + * read from {@link #getKey()} like the prefix of a material written in a file + * ({@code itemsadder} in {@code itemsadder:ruby}). + * + * @return the key + */ + @Override + public @NotNull Key key() { + return ZMenuKeys.parse(this.key); + } + /** * Loads an ItemStack based on the provided configuration. * diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/PermissibleLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/PermissibleLoader.java index 1e9f3e413..fb538864b 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/loader/PermissibleLoader.java +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/PermissibleLoader.java @@ -1,10 +1,13 @@ package fr.maxlego08.menu.api.loader; import fr.maxlego08.menu.api.ButtonManager; +import fr.maxlego08.menu.api.ZMenuKeys; import fr.maxlego08.menu.api.pattern.ActionPattern; import fr.maxlego08.menu.api.requirement.Action; import fr.maxlego08.menu.api.requirement.Permissible; import fr.maxlego08.menu.api.utils.TypedMapAccessor; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -14,7 +17,7 @@ import java.util.List; import java.util.Map; -public abstract class PermissibleLoader { +public abstract class PermissibleLoader implements Keyed { private final String key; @@ -33,6 +36,17 @@ public String getKey(){ return this.key; } + /** + * The key of this type in {@link fr.maxlego08.menu.api.registry.Registries#PERMISSIBLE}, read from + * {@link #getKey()} like a {@code type} written in a file. + * + * @return the key + */ + @Override + public @NotNull Key key() { + return ZMenuKeys.parse(this.key); + } + /** * Creates a {@link Permissible} based on the provided configuration. * diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java new file mode 100644 index 000000000..f7c9904cf --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java @@ -0,0 +1,70 @@ +package fr.maxlego08.menu.api.loader.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; + +/** + * Builds a {@link SlotAnimation} from the options written next to the {@code type} of a group. + * It is what the slot animation registry holds, under its {@link #key() key} and its + * {@link #aliases() aliases}. + * + *

Most animations ignore those options entirely, but some, such as the one drawn with a + * matrix, are configured per inventory.

+ * + *

Register one with + * {@link fr.maxlego08.menu.api.InventoryManager#registerSlotAnimation(org.bukkit.plugin.Plugin, SlotAnimationLoader)}:

+ * + *
+ * public class MyAnimationLoader extends SlotAnimationLoader {
+ *     public MyAnimationLoader() {
+ *         super(Key.key("myplugin", "my_animation"));
+ *     }
+ *
+ *     public SlotAnimation create(Map<String, Object> options) {
+ *         return new MyAnimation();
+ *     }
+ * }
+ * 
+ */ +public abstract class SlotAnimationLoader implements Keyed { + + private final Key key; + private final List aliases; + + /** + * @param key the name menus use as {@code type} + * @param aliases other names for the same animation + */ + protected SlotAnimationLoader(@NotNull Key key, @NotNull Key... aliases) { + this.key = key; + this.aliases = List.of(aliases); + } + + @Override + public @NotNull Key key() { + return this.key; + } + + /** + * @return the other names of this animation + */ + public @NotNull List aliases() { + return this.aliases; + } + + /** + * Builds the animation. + * + * @param options the values of the section the {@code type} was read from + * @return the animation + * @throws IllegalArgumentException if the options are missing or invalid, with a message meant + * to be shown to whoever wrote the file + */ + @NotNull + public abstract SlotAnimation create(@NotNull Map options); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/mechanic/MechanicFactory.java b/API/src/main/java/fr/maxlego08/menu/api/mechanic/MechanicFactory.java index c1f6d5f59..ddfe78415 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/mechanic/MechanicFactory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/mechanic/MechanicFactory.java @@ -2,6 +2,9 @@ import fr.maxlego08.menu.api.ItemManager; import fr.maxlego08.menu.api.MenuPlugin; +import fr.maxlego08.menu.api.ZMenuKeys; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.inventory.ItemStack; @@ -16,7 +19,7 @@ import java.util.Set; @SuppressWarnings("unused") -public abstract class MechanicFactory> { +public abstract class MechanicFactory> implements Keyed { private final MenuPlugin plugin; private final ItemManager itemManager; private final String mechanicId; @@ -57,6 +60,17 @@ public String getMechanicId() { return this.mechanicId; } + /** + * The key of this factory in {@link fr.maxlego08.menu.api.registry.Registries#MECHANIC}, read from + * {@link #getMechanicId()} like a mechanic written in a custom item file. + * + * @return the key + */ + @Override + public @NotNull Key key() { + return ZMenuKeys.parse(this.mechanicId); + } + @Contract @NotNull public MenuPlugin getPlugin() { diff --git a/API/src/main/java/fr/maxlego08/menu/api/pagination/PaginationManager.java b/API/src/main/java/fr/maxlego08/menu/api/pagination/PaginationManager.java index f7d7d1e6c..70096c902 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/pagination/PaginationManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/pagination/PaginationManager.java @@ -88,7 +88,33 @@ default boolean hasState(@NotNull UUID playerId, @NotNull String contextId) { void removeState(@NotNull UUID playerId, @NotNull String contextId); /** - * Removes all pagination states for a player. + * Gets the page of the zMenu inventory a player opened last. + * + * @param playerId the player's UUID + * @return the page, starting at 1, or 0 if the player has not opened an inventory since they joined + */ + int getMenuPage(@NotNull UUID playerId); + + /** + * Gets the number of pages of the zMenu inventory a player opened last. + * + * @param playerId the player's UUID + * @return the number of pages, or 0 if the player has not opened an inventory since they joined + */ + int getMenuMaxPage(@NotNull UUID playerId); + + /** + * Remembers the page of the zMenu inventory a player is opening, read by the {@code player_page} + * and {@code player_max_page} placeholders. + * + * @param playerId the player's UUID + * @param page the page, starting at 1 + * @param maxPage the number of pages of the inventory + */ + void setMenuPage(@NotNull UUID playerId, int page, int maxPage); + + /** + * Removes all pagination states for a player, and the page of the inventory they opened last. * Useful on player logout. * * @param playerId the player's UUID diff --git a/API/src/main/java/fr/maxlego08/menu/api/placeholder/Placeholder.java b/API/src/main/java/fr/maxlego08/menu/api/placeholder/Placeholder.java index 26542f16f..8dfc79029 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/placeholder/Placeholder.java +++ b/API/src/main/java/fr/maxlego08/menu/api/placeholder/Placeholder.java @@ -55,24 +55,32 @@ public List setPlaceholders(OfflinePlayer player, List list) { class Local implements Placeholder { + private static String restorePercent(String string) { + return string == null ? null : string.replace("", "%"); + } + + private static List restorePercent(List list) { + return list == null ? null : list.stream().map(Local::restorePercent).toList(); + } + @Override public String setPlaceholders(Player player, String string) { - return LocalPlaceholder.getInstance().setPlaceholders(player, string); + return restorePercent(LocalPlaceholder.getInstance().setPlaceholders(player, string)); } @Override public String setPlaceholders(OfflinePlayer player, String string) { - return LocalPlaceholder.getInstance().setPlaceholders(player, string); + return restorePercent(LocalPlaceholder.getInstance().setPlaceholders(player, string)); } @Override public List setPlaceholders(Player player, List list) { - return LocalPlaceholder.getInstance().setPlaceholders(player, list); + return restorePercent(LocalPlaceholder.getInstance().setPlaceholders(player, list)); } @Override public List setPlaceholders(OfflinePlayer player, List list) { - return LocalPlaceholder.getInstance().setPlaceholders(player, list); + return restorePercent(LocalPlaceholder.getInstance().setPlaceholders(player, list)); } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java new file mode 100644 index 000000000..df2d71778 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java @@ -0,0 +1,54 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.stream.Stream; + +@ApiStatus.NonExtendable +public interface KeyedRegistry extends Iterable { + + @NotNull RegistryType type(); + + @NotNull Optional get(@NotNull Key id); + + default @NotNull T getOrThrow(@NotNull Key id) { + return this.get(id).orElseThrow(() -> new NoSuchElementException("No entry " + id.asString() + " in registry " + this.type())); + } + + boolean contains(@NotNull Key id); + + boolean containsValue(@NotNull T value); + + /** + * @return the name of what registered {@code id}, if it exists + */ + @NotNull Optional owner(@NotNull Key id); + + /** + * @return the other names of entries, from the alias to the entry it points to + */ + default @NotNull Map aliases() { + return Map.of(); + } + + @NotNull Set keys(); + + @NotNull List values(); + + default @NotNull Stream stream() { + return this.values().stream(); + } + + default int size() { + return this.values().size(); + } + + @Override + default @NotNull Iterator iterator() { + return this.values().iterator(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java new file mode 100644 index 000000000..20cc2f5d5 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java @@ -0,0 +1,14 @@ +package fr.maxlego08.menu.api.registry; + +import org.jetbrains.annotations.NotNull; + +/** + * Thrown when a value cannot be registered: the registry is frozen, the key is already taken, or + * the value is not of the type the registry holds. + */ +public final class RegistrationException extends RuntimeException { + + public RegistrationException(@NotNull String message) { + super(message); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java b/API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java new file mode 100644 index 000000000..f9aef7d87 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java @@ -0,0 +1,86 @@ +package fr.maxlego08.menu.api.registry; + +import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.InventoryOptionType; +import fr.maxlego08.menu.api.button.ButtonOptionType; +import fr.maxlego08.menu.api.ZMenuKeys; +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.command.CommandArgumentValidator; +import fr.maxlego08.menu.api.itemstack.ItemStackSimilar; +import fr.maxlego08.menu.api.loader.ActionLoader; +import fr.maxlego08.menu.api.loader.ButtonLoader; +import fr.maxlego08.menu.api.loader.ItemComponentLoader; +import fr.maxlego08.menu.api.loader.MaterialLoader; +import fr.maxlego08.menu.api.loader.PermissibleLoader; +import fr.maxlego08.menu.api.mechanic.MechanicFactory; +import fr.maxlego08.menu.api.rules.loader.RuleLoader; +import fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader; + +public final class Registries { + public static final RegistryType SLOT_ANIMATION = RegistryType.createStatic(ZMenuKeys.key("slot_animation"), SlotAnimationLoader.class); + + public static final RegistryType INVENTORY = RegistryType.createReloadable(ZMenuKeys.key("inventory"), Inventory.class); + + public static final RegistryType ANIMATION = RegistryType.createReloadable(ZMenuKeys.key("animation"), Animation.class); + + /** + * The requirement types a menu can use, by their {@code type}: {@code permission}, {@code placeholder}... + */ + public static final RegistryType PERMISSIBLE = RegistryType.createOpen(ZMenuKeys.key("permissible"), PermissibleLoader.class); + + /** + * The action types a menu can run, by their {@code type}: {@code message}, {@code console_command}... + */ + public static final RegistryType ACTION = RegistryType.createOpen(ZMenuKeys.key("action"), ActionLoader.class); + + /** + * The button types a menu can use, by their {@code type}: {@code next}, {@code inventory}... + */ + public static final RegistryType BUTTON = RegistryType.createOpen(ZMenuKeys.key("button"), ButtonLoader.class); + + /** + * The item rule types, by their {@code type}: {@code material}, {@code tag}, {@code and}... + */ + public static final RegistryType RULE = RegistryType.createOpen(ZMenuKeys.key("rule"), RuleLoader.class); + + /** + * The ways to compare two items, by name: {@code full}, {@code material}, {@code lore}... + */ + public static final RegistryType ITEM_VERIFICATION = RegistryType.createOpen(ZMenuKeys.key("item_verification"), ItemStackSimilar.class); + + /** + * The loaders of items from other plugins, by the prefix of a material: {@code itemsadder}, + * {@code hdb}, {@code base64}... + */ + public static final RegistryType MATERIAL_LOADER = RegistryType.createOpen(ZMenuKeys.key("material_loader"), MaterialLoader.class); + + /** + * The item components an item can set, by name: {@code minecraft:lore} (also {@code lore}), + * {@code myplugin:glow}... + */ + public static final RegistryType ITEM_COMPONENT = RegistryType.createOpen(ZMenuKeys.key("item_component"), ItemComponentLoader.class); + + /** + * The argument types a custom command can check, by their {@code type}: {@code player}, + * {@code integer}, {@code material}... + */ + public static final RegistryType COMMAND_ARGUMENT_VALIDATOR = RegistryType.createOpen(ZMenuKeys.key("command_argument_validator"), CommandArgumentValidator.class); + + /** + * The mechanics a custom item can have, by their id: {@code itemjoin}, {@code on-click}... + */ + public static final RegistryType> MECHANIC = RegistryType.>createOpen(ZMenuKeys.key("mechanic"), MechanicFactory.class); + + /** + * The options added to every button, such as zMenuPlus's {@code error-item}. + */ + public static final RegistryType BUTTON_OPTION = RegistryType.createOpen(ZMenuKeys.key("button_option"), ButtonOptionType.class); + + /** + * The options added to every inventory, such as zMenuPlus's conditional names. + */ + public static final RegistryType INVENTORY_OPTION = RegistryType.createOpen(ZMenuKeys.key("inventory_option"), InventoryOptionType.class); + + private Registries() { + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java index 2aa9bc7f4..b2491e9ee 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java @@ -8,6 +8,11 @@ import java.util.Optional; import java.util.Set; +/** + * @deprecated a plain map with no owner and no duplicate detection. Use a {@link RegistryType} + * held by the {@link RegistryManager} instead. + */ +@Deprecated(since = "1.1.2.1") public abstract class Registry { protected final Map registry = new HashMap<>(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java new file mode 100644 index 000000000..348fc999d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java @@ -0,0 +1,21 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Optional; + +@ApiStatus.NonExtendable +public interface RegistryAccess { + /** + * @throws IllegalArgumentException if no such registry exists + */ + @NotNull KeyedRegistry registry(@NotNull RegistryType type); + + @NotNull Optional> find(@NotNull Key registryKey); + + @NotNull Collection> types(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java new file mode 100644 index 000000000..d62e3a64d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java @@ -0,0 +1,23 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +public record RegistryKey(@NotNull RegistryType registry, @NotNull Key id) { + + public static @NotNull RegistryKey of(@NotNull RegistryType registry, @NotNull Key id) { + return new RegistryKey<>(registry, id); + } + + public @NotNull Optional resolve(@NotNull RegistryAccess access) { + return access.registry(this.registry).get(this.id); + } + + @Override + public @NotNull String toString() { + return this.registry + "/" + this.id.asString(); + } +} \ No newline at end of file diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java new file mode 100644 index 000000000..23de8944d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java @@ -0,0 +1,66 @@ +package fr.maxlego08.menu.api.registry; + +import fr.maxlego08.menu.api.registry.reload.ReloadReport; +import fr.maxlego08.menu.api.registry.reload.ReloadValidator; +import fr.maxlego08.menu.api.registry.reload.ReloadableProvider; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Every registry of zMenu. Read them as a {@link RegistryAccess}, and register your own entries + * with {@link #writable(RegistryType)}. + * + *

Get it with {@link fr.maxlego08.menu.api.InventoryManager#getRegistries()}. Static registries + * stop accepting entries once the server has finished loading, so register from your + * {@code onEnable}.

+ */ +@ApiStatus.NonExtendable +public interface RegistryManager extends RegistryAccess { + + /** + * Gets the write side of a registry. + * + * @param type the registry + * @param the type of its entries + * @return the registry, to register entries in + * @throws IllegalArgumentException if no such registry exists + */ + @NotNull WritableRegistry writable(@NotNull RegistryType type); + + /** + * Adds a provider to a reloadable registry. It runs on the next {@link #reload(RegistryType)}. + * + * @param type the reloadable registry + * @param owner the name of what provides, shown in reports + * @param provider the provider + * @throws IllegalArgumentException if no such reloadable registry exists + */ + void provider(@NotNull RegistryType type, @NotNull String owner, @NotNull ReloadableProvider provider); + + /** + * Adds a check run on every new snapshot, before it is published. + * + * @throws IllegalArgumentException if no such reloadable registry exists + */ + void validator(@NotNull RegistryType type, @NotNull String owner, @NotNull ReloadValidator validator); + + /** + * Rebuilds a reloadable registry from its providers. The new snapshot replaces the live one + * only if nothing reported an error; otherwise the previous snapshot stays live. + * + * @param type the reloadable registry + * @return what was found + * @throws IllegalArgumentException if no such reloadable registry exists + */ + @NotNull ReloadReport reload(@NotNull RegistryType type); + + /** + * Reloads every reloadable registry, in the order they were created. + * + * @return one report per registry + */ + @NotNull List reloadAll(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java new file mode 100644 index 000000000..194dcfedd --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java @@ -0,0 +1,45 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +public record RegistryType(@NotNull Key key, @NotNull Class valueType, @NotNull Kind kind) implements Keyed { + + public enum Kind { + /** Filled during registration, then frozen for the server lifetime. */ + STATIC, + /** Rebuilt from providers on every reload and swapped atomically. */ + RELOADABLE, + /** + * Accepts entries for the whole server lifetime; the entries of a plugin are removed when it + * disables, so that a plugin reloaded at runtime can register them again. + */ + OPEN + } + + public static @NotNull RegistryType createStatic(@NotNull Key key, @NotNull Class valueType) { + return new RegistryType<>(key, valueType, Kind.STATIC); + } + + public static @NotNull RegistryType createOpen(@NotNull Key key, @NotNull Class valueType) { + return new RegistryType<>(key, valueType, Kind.OPEN); + } + + public static @NotNull RegistryType createReloadable(@NotNull Key key, @NotNull Class valueType) { + return new RegistryType<>(key, valueType, Kind.RELOADABLE); + } + + public boolean isReloadable() { + return this.kind == Kind.RELOADABLE; + } + + public boolean isOpen() { + return this.kind == Kind.OPEN; + } + + @Override + public @NotNull String toString() { + return this.key.asString(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RuleLoaderRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RuleLoaderRegistry.java index 631dd06fe..881ceca7d 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/registry/RuleLoaderRegistry.java +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RuleLoaderRegistry.java @@ -10,6 +10,11 @@ import java.util.Map; import java.util.Optional; +/** + * @deprecated rule types live in {@link Registries#RULE}; zMenu no longer has a rule loader registry of + * this kind. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public abstract class RuleLoaderRegistry extends Registry{ protected RuleLoaderRegistry() { diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java new file mode 100644 index 000000000..e8a7e669e --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java @@ -0,0 +1,94 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +/** + * The write side of a {@link KeyedRegistry}, used while plugins register their entries. + * + *

A static registry stops accepting entries once the server has finished loading, so register + * from your {@code onEnable}. An {@link RegistryType.Kind#OPEN open} registry accepts entries at any + * time and forgets those of a plugin when it disables.

+ * + * @param the type of the entries + */ +@ApiStatus.NonExtendable +public interface WritableRegistry { + + @NotNull RegistryType type(); + + /** + * Registers an entry under its own {@link Keyed#key() key}. + * + * @param owner the name of what registers it, shown when two entries clash + * @param value the entry + * @return the entry + * @throws RegistrationException if the registry is frozen, the key is taken, or the value has the wrong type + */ + @NotNull V register(@NotNull String owner, @NotNull V value); + + default @NotNull V register(@NotNull Plugin owner, @NotNull V value) { + return this.register(owner.getName(), value); + } + + /** + * Registers an entry under its own key, and under each of its other names. A name equal to the + * key, or given twice, is registered once. Either every name is registered, or none is. + * + * @param owner the name of what registers it, shown when two entries clash + * @param value the entry + * @param aliases the other names of the entry + * @return the entry + * @throws RegistrationException if the registry is frozen, a name is taken, or the value has the wrong type + */ + default @NotNull V register(@NotNull String owner, @NotNull V value, @NotNull Collection aliases) { + this.register(owner, value); + try { + aliases.stream().distinct().filter(alias -> !alias.equals(value.key())).forEach(alias -> this.alias(owner, alias, value.key())); + } catch (RegistrationException exception) { + this.unregister(value.key()); + throw exception; + } + return value; + } + + default @NotNull V register(@NotNull Plugin owner, @NotNull V value, @NotNull Collection aliases) { + return this.register(owner.getName(), value, aliases); + } + + /** + * Makes {@code from} another name of the entry {@code to}. + * + * @throws RegistrationException if the registry is frozen, {@code from} is taken, or {@code to} does not exist + */ + void alias(@NotNull String owner, @NotNull Key from, @NotNull Key to); + + boolean contains(@NotNull Key id); + + /** + * Removes an entry and every name pointing to it, or a single alias. + * + * @param id the key of the entry, or an alias + * @return whether something was removed + * @throws RegistrationException if the registry is frozen + */ + boolean unregister(@NotNull Key id); + + /** + * Removes every entry and alias registered by an owner. + * + * @param owner the name the entries were registered with + * @return how many entries and aliases were removed + * @throws RegistrationException if the registry is frozen + */ + int unregisterAll(@NotNull String owner); + + default int unregisterAll(@NotNull Plugin owner) { + return this.unregisterAll(owner.getName()); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java new file mode 100644 index 000000000..6e5a93593 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java @@ -0,0 +1,50 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.KeyedRegistry; +import fr.maxlego08.menu.api.registry.RegistryAccess; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Given to a {@link ReloadableProvider} while a new snapshot is being built. + * + * @param the type of the entries + */ +@ApiStatus.NonExtendable +public interface ReloadContext extends ReloadReporter { + + /** + * @return the name of the provider being run + */ + @NotNull String owner(); + + /** + * @return the registries, where the one being reloaded reads the snapshot under construction + */ + @NotNull RegistryAccess registries(); + + /** + * Adds an entry to the new snapshot. A key that is already taken is reported as an error. + * + * @param value the entry + * @return the entry + */ + @NotNull V register(@NotNull V value); + + /** + * Gets the snapshot that is live while this one is being built, to keep an entry whose file + * cannot be read anymore. + * + * @return the live snapshot + */ + @NotNull KeyedRegistry previous(); + + /** + * Runs an action once this snapshot has replaced the live one, such as firing a load event. + * The action never runs if the snapshot is rejected. + * + * @param action the action + */ + void onPublish(@NotNull Runnable action); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java new file mode 100644 index 000000000..cc163b489 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java @@ -0,0 +1,60 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.RegistryType; +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * What a reload of a reloadable registry found. + * + *

When there is an error, the new snapshot is rejected and the previous one stays live.

+ * + * @param type the registry that was reloaded + * @param issues everything providers and validators reported + * @param applied whether the new snapshot replaced the previous one + * @param generation the generation of the registry after the reload + */ +public record ReloadReport(@NotNull RegistryType type, @NotNull List issues, boolean applied, long generation) { + + public ReloadReport { + issues = List.copyOf(issues); + } + + public enum Severity { + WARNING, + ERROR + } + + /** + * @param severity how serious it is + * @param owner the provider or validator that reported it + * @param subject the entry it is about, if any + * @param message what is wrong, meant for whoever wrote the file + */ + public record Issue(@NotNull Severity severity, @NotNull String owner, @Nullable Key subject, @NotNull String message) { + } + + public boolean hasErrors() { + return this.issues.stream().anyMatch(issue -> issue.severity() == Severity.ERROR); + } + + public @NotNull List errors() { + return this.issues.stream().filter(issue -> issue.severity() == Severity.ERROR).toList(); + } + + public @NotNull List warnings() { + return this.issues.stream().filter(issue -> issue.severity() == Severity.WARNING).toList(); + } + + /** + * @return one line per issue, ready to be logged + */ + public @NotNull List format() { + return this.issues.stream() + .map(issue -> "[" + issue.severity() + "] " + issue.owner() + (issue.subject() == null ? "" : " " + issue.subject().asString()) + ": " + issue.message()) + .toList(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java new file mode 100644 index 000000000..c298ef6a7 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java @@ -0,0 +1,30 @@ +package fr.maxlego08.menu.api.registry.reload; + +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Collects what goes wrong while a snapshot is being built. An error rejects the whole snapshot, + * a warning only gets reported. + */ +public interface ReloadReporter { + + void report(@NotNull ReloadReport.Severity severity, @Nullable Key subject, @NotNull String message); + + default void error(@NotNull String message) { + this.report(ReloadReport.Severity.ERROR, null, message); + } + + default void error(@NotNull Key subject, @NotNull String message) { + this.report(ReloadReport.Severity.ERROR, subject, message); + } + + default void warn(@NotNull String message) { + this.report(ReloadReport.Severity.WARNING, null, message); + } + + default void warn(@NotNull Key subject, @NotNull String message) { + this.report(ReloadReport.Severity.WARNING, subject, message); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java new file mode 100644 index 000000000..ce3b626ae --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java @@ -0,0 +1,23 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.KeyedRegistry; +import fr.maxlego08.menu.api.registry.RegistryAccess; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +/** + * Checks a new snapshot once every provider has run, before it replaces the live one. This is the + * place for checks that need several entries, such as a menu opening another menu. + * + * @param the type of the entries + */ +@FunctionalInterface +public interface ReloadValidator { + + /** + * @param snapshot the new snapshot + * @param registries the registries, where the one being reloaded is {@code snapshot} + * @param reporter where to report what is wrong; an error rejects the snapshot + */ + void validate(@NotNull KeyedRegistry snapshot, @NotNull RegistryAccess registries, @NotNull ReloadReporter reporter); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java new file mode 100644 index 000000000..4faaf9525 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java @@ -0,0 +1,16 @@ +package fr.maxlego08.menu.api.registry.reload; + +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +/** + * Contributes entries to a reloadable registry, typically by reading files. It runs on every + * reload, so it must not keep state between two runs. + * + * @param the type of the entries + */ +@FunctionalInterface +public interface ReloadableProvider { + + void provide(@NotNull ReloadContext context); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java new file mode 100644 index 000000000..9b4e35c24 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java @@ -0,0 +1,19 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.KeyedRegistry; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; + +/** + * A registry rebuilt from its providers on every reload. Reads always see one complete snapshot. + * + * @param the type of the entries + */ +@ApiStatus.NonExtendable +public interface ReloadableRegistry extends KeyedRegistry { + + /** + * @return how many snapshots were published, so that a cache can tell it is stale + */ + long generation(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/rules/loader/RuleLoader.java b/API/src/main/java/fr/maxlego08/menu/api/rules/loader/RuleLoader.java index c0ab4bbdf..b315e0bf0 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/rules/loader/RuleLoader.java +++ b/API/src/main/java/fr/maxlego08/menu/api/rules/loader/RuleLoader.java @@ -1,6 +1,9 @@ package fr.maxlego08.menu.api.rules.loader; +import fr.maxlego08.menu.api.ZMenuKeys; import fr.maxlego08.menu.api.rules.Rule; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -8,7 +11,7 @@ import java.util.List; import java.util.Map; -public interface RuleLoader { +public interface RuleLoader extends Keyed { @NotNull String getType(); @@ -17,6 +20,26 @@ public interface RuleLoader { return Collections.emptyList(); } + /** + * The key of this rule type in {@link fr.maxlego08.menu.api.registry.Registries#RULE}, read from + * {@link #getType()} like a {@code type} written in a file. + * + * @return the key + */ + @Override + default @NotNull Key key() { + return ZMenuKeys.parse(this.getType()); + } + + /** + * {@link #getAliases()} read like the key. + * + * @return the other names, as keys + */ + default @NotNull List aliases() { + return this.getAliases().stream().map(ZMenuKeys::parse).toList(); + } + @Nullable Rule load(@NotNull Map configuration); } diff --git a/API/src/main/java/fr/maxlego08/menu/api/sound/SoundOption.java b/API/src/main/java/fr/maxlego08/menu/api/sound/SoundOption.java index a3240ed02..706b69bad 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/sound/SoundOption.java +++ b/API/src/main/java/fr/maxlego08/menu/api/sound/SoundOption.java @@ -1,10 +1,12 @@ package fr.maxlego08.menu.api.sound; import com.cryptomorin.xseries.XSound; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Entity; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -67,4 +69,15 @@ default void serialize(@NotNull Map map) { if (!XSound.Category.MASTER.name().equals(this.getCategoryName())) map.put("sound-category", this.getCategoryName()); } + /** + * Writes this sound into a section of its own, with the keys of {@link #serialize(Map)}. + * + * @param section The section to write into. + */ + default void serialize(@NotNull ConfigurationSection section) { + Map map = new LinkedHashMap<>(); + this.serialize(map); + map.forEach(section::set); + } + } diff --git a/API/src/main/java/fr/maxlego08/menu/api/sound/SoundService.java b/API/src/main/java/fr/maxlego08/menu/api/sound/SoundService.java new file mode 100644 index 000000000..5eba44364 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/sound/SoundService.java @@ -0,0 +1,50 @@ +package fr.maxlego08.menu.api.sound; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Creates the sounds zMenu plays, and reads them from configuration files. + * + * @see fr.maxlego08.menu.api.MenuPlugin#getSoundService() + */ +public interface SoundService { + + /** + * Creates a sound. A name Minecraft does not know is played as a custom sound, such as one + * from a resource pack. + * + * @param sound the name of the sound + * @param category the sound category, such as {@code MASTER} + * @param pitch the pitch + * @param volume the volume + * @return the sound + */ + @NotNull + SoundOption create(@NotNull String sound, @NotNull String category, float pitch, float volume); + + /** + * Creates a sound whose pitch is picked between two values every time it plays. + * + * @param sound the name of the sound + * @param category the sound category, such as {@code MASTER} + * @param minPitch the lowest pitch + * @param maxPitch the highest pitch + * @param volume the volume + * @return the sound + */ + @NotNull + SoundOption createRandomPitch(@NotNull String sound, @NotNull String category, float minPitch, float maxPitch, float volume); + + /** + * Reads a sound written as a section: {@code sound}, {@code volume}, {@code category} (or + * {@code sound-category}) and {@code pitch}, either a number or a {@code [min, max]} range. + * + * @param configuration the configuration + * @param path the path of the section, without a trailing dot + * @return the sound, or null if the section has no {@code sound} + */ + @Nullable + SoundOption load(@NotNull YamlConfiguration configuration, @NotNull String path); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java b/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java index 41ab33d08..bb2b04cfe 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java @@ -6,8 +6,10 @@ import fr.maxlego08.menu.api.storage.dto.InventoryDTO; import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -54,4 +56,34 @@ default void flush() { Set getVisitedWorlds(@NotNull UUID playerId); void markWorldVisited(@NotNull UUID playerId, @NotNull String worldName); + + /** + * Reads back when a player last saw each animation that remembers itself. + * + *

This hits the database, so it must not be called from the main thread. It is meant to be + * loaded once when the player joins and kept in memory afterwards, because a menu opens far + * too often to ask the database each time.

+ * + * @param playerId the player + * @return the time each animation last played, in milliseconds since the epoch + */ + @NotNull + Map loadPlayedAnimations(@NotNull UUID playerId); + + /** + * Records that a player has just seen an animation. + * + * @param playerId the player + * @param animationKey the animation, as the inventory identifies it + * @param playedAt when it played, in milliseconds since the epoch + */ + void markAnimationPlayed(@NotNull UUID playerId, @NotNull String animationKey, long playedAt); + + /** + * Forgets that a player has seen an animation, so one that only plays once plays again. + * + * @param playerId the player + * @param animationKey the animation to forget, or null to forget all of them + */ + void clearPlayedAnimations(@NotNull UUID playerId, @Nullable String animationKey); } diff --git a/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java b/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java index 2791f3467..1abbe0103 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java +++ b/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java @@ -11,5 +11,6 @@ public interface Tables { @NotNull String PLAYER_DATAS = "%prefix%player_datas"; @NotNull String PLAYER_INVENTORIES = "%prefix%player_inventories"; @NotNull String FIRST_WORLD_JOIN = "%prefix%first_world_join"; + @NotNull String PLAYER_ANIMATIONS = "%prefix%player_animations"; } diff --git a/API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java b/API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java new file mode 100644 index 000000000..f617a09e2 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java @@ -0,0 +1,13 @@ +package fr.maxlego08.menu.api.storage.dto; + +import java.util.UUID; + +/** + * The last time a player saw one animation. + * + * @param playerId the player + * @param animationKey the animation, as the inventory identifies it + * @param playedAt when it last played, in milliseconds since the epoch + */ +public record PlayerAnimationDTO(UUID playerId, String animationKey, long playedAt) { +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/ClickAction.java b/API/src/main/java/fr/maxlego08/menu/api/utils/ClickAction.java index 7c0421135..67b6e0fa3 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/ClickAction.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/ClickAction.java @@ -5,6 +5,10 @@ import java.util.List; +/** + * @deprecated not used by zMenu. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public enum ClickAction { LEFT_CLICK(Action.LEFT_CLICK_BLOCK, Action.LEFT_CLICK_AIR), RIGHT_CLICK(Action.RIGHT_CLICK_BLOCK, Action.RIGHT_CLICK_AIR), diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/CompatibilityUtil.java b/API/src/main/java/fr/maxlego08/menu/api/utils/CompatibilityUtil.java index 0d4886747..7d3192a6a 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/CompatibilityUtil.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/CompatibilityUtil.java @@ -14,6 +14,22 @@ */ public class CompatibilityUtil { + /** + * getTopInventory is called on every move, click and packet, so the lookup is resolved once per view class. + * Holds null when the class has no such method. + */ + private static final ClassValue TOP_INVENTORY_METHODS = new ClassValue<>() { + @Override + protected Method computeValue(@NotNull Class type) { + try { + Method method = type.getMethod("getTopInventory"); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException | RuntimeException e) { + return null; + } + } + }; /** * In API versions 1.20.6 and earlier, InventoryView is a class. @@ -26,24 +42,22 @@ public class CompatibilityUtil { */ @Nullable public static Inventory getTopInventory(@NotNull InventoryEvent event) { - try { - Object view = event.getView(); - Method getTopInventory = view.getClass().getMethod("getTopInventory"); - getTopInventory.setAccessible(true); - return (Inventory) getTopInventory.invoke(view); - } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { - return null; - } + return getTopInventory(event.getView()); } @Nullable public static Inventory getTopInventory(@NotNull Player player) { + return getTopInventory((Object) player.getOpenInventory()); + } + + @Nullable + private static Inventory getTopInventory(@Nullable Object view) { + if (view == null) return null; + Method getTopInventory = TOP_INVENTORY_METHODS.get(view.getClass()); + if (getTopInventory == null) return null; try { - Object view = player.getOpenInventory(); - Method getTopInventory = view.getClass().getMethod("getTopInventory"); - getTopInventory.setAccessible(true); return (Inventory) getTopInventory.invoke(view); - } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + } catch (InvocationTargetException | IllegalAccessException e) { return null; } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/EnumInventory.java b/API/src/main/java/fr/maxlego08/menu/api/utils/EnumInventory.java index 8c1d1329f..809145971 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/EnumInventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/EnumInventory.java @@ -12,7 +12,10 @@ public enum EnumInventory { /** * Inventory specifically for marketplace interfaces. + * + * @deprecated no inventory uses it. */ + @Deprecated(since = "1.1.2.1", forRemoval = true) INVENTORY_MARKETPLACE(2), ; diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java b/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java index a09a7953e..6a3597e68 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java @@ -2,12 +2,12 @@ import com.google.common.base.Preconditions; import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.ZMenuKeys; import org.bukkit.configuration.ConfigurationSection; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.List; -import java.util.Objects; public record InventoryReplacement(String inventoryName, String plugin, List pages, InventoryReplacementMode mode) { public static final String DEFAULT_PLUGIN = "zMenu"; @@ -27,12 +27,7 @@ public InventoryReplacement(String inventoryName, String plugin, List p public boolean shouldTrigger(Inventory inventory, int page) { - // Inventory Name - if (!Objects.equals(this.inventoryName, inventory.getFileName())) { - return false; - } - // Plugin Name - if (!Objects.equals(this.plugin, inventory.getPlugin().getName())) { + if (this.inventoryName == null || !inventory.key().equals(ZMenuKeys.fileKey(this.plugin, this.inventoryName))) { return false; } diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java b/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java index 9c71200c7..228457506 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java @@ -94,6 +94,9 @@ public enum Message implements IMessage { GIVE_ITEM_SUCCESS_SELF("&aYou have received the item &f%itemId%&a."), GIVE_ITEM_SUCCESS_OTHER("&aYou have given the item &f%itemId%&a to &3%player%&a."), + ANIMATION_RESET_ALL("&aThe animations of &f%player% &awill all play again."), + ANIMATION_RESET_ONE("&aThe animation of &f%menu% &awill play again for &f%player%&a."), + PLAYERS_DATA_CLEAR_ALL("&aYou have just deleted the datas of all the players."), PLAYERS_DATA_CLEAR_PLAYER("&aYou have just deleted the player's data &f%player%&a."), diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/OfflinePlayerCache.java b/API/src/main/java/fr/maxlego08/menu/api/utils/OfflinePlayerCache.java index 3e9444815..4f6e3e82e 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/OfflinePlayerCache.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/OfflinePlayerCache.java @@ -19,10 +19,9 @@ public class OfflinePlayerCache { */ @NotNull public static OfflinePlayer get(@NotNull String playerName) { - return offlinePlayer.computeIfAbsent(playerName, name -> { - Player player = Bukkit.getPlayer(name); - return player != null ? player : Bukkit.getOfflinePlayer(name); - }); + Player player = Bukkit.getPlayer(playerName); + if (player != null) return player; + return offlinePlayer.computeIfAbsent(playerName, Bukkit::getOfflinePlayer); } public static void clearCache(){ diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/PlatformType.java b/API/src/main/java/fr/maxlego08/menu/api/utils/PlatformType.java index c6d385002..e057a0d72 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/PlatformType.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/PlatformType.java @@ -1,5 +1,11 @@ package fr.maxlego08.menu.api.utils; +/** + * The server platform. + * + * @deprecated zMenu only runs on Paper and its forks, so the platform is always {@link #PAPER}. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public enum PlatformType { /** * Paper server with native Adventure API support. diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/TrimHelper.java b/API/src/main/java/fr/maxlego08/menu/api/utils/TrimHelper.java index 0fd0b5a7d..3a11b18f4 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/TrimHelper.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/TrimHelper.java @@ -9,7 +9,10 @@ /** * Utility/helper for looking up available trim materials and patterns by name for item customization. + * + * @deprecated not used by zMenu; trim materials and patterns are read from Paper's registries. */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public class TrimHelper { private final Map trimMaterials = new HashMap<>(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/Tuples.java b/API/src/main/java/fr/maxlego08/menu/api/utils/Tuples.java deleted file mode 100644 index ba6124606..000000000 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/Tuples.java +++ /dev/null @@ -1,13 +0,0 @@ -package fr.maxlego08.menu.api.utils; - -/** - * Generic tuple class storing two values of different types. - * Useful for returning pairs from methods where a dedicated class is unnecessary. - * - * @param Type of the first element - * @param Type of the second element - * @param first The first element in the tuple. - * @param second The second element in the tuple. - */ -public record Tuples(T first, U second) { -} diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZDamageReductionRecord.java b/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZDamageReductionRecord.java index 5f22e0a51..5580988da 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZDamageReductionRecord.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZDamageReductionRecord.java @@ -4,6 +4,10 @@ import java.util.List; +/** + * @deprecated not used by zMenu. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public record ZDamageReductionRecord( List damageTypes, float base, diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZPersistentDataType.java b/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZPersistentDataType.java index 9f572f679..9d0410e76 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZPersistentDataType.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZPersistentDataType.java @@ -3,6 +3,10 @@ import org.bukkit.NamespacedKey; import org.bukkit.persistence.PersistentDataType; +/** + * @deprecated not used by zMenu. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public record ZPersistentDataType(NamespacedKey namespacedKey, PersistentDataType persistentDataType, T value) { } diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZToolRule.java b/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZToolRule.java index a05f2bf3e..fbfc7699d 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZToolRule.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/itemstack/ZToolRule.java @@ -4,6 +4,6 @@ * @deprecated The tool component now keeps its rules as * {@link fr.maxlego08.menu.api.utils.resolvable.paper.ResolvableToolRule}, in order and with optional values. */ -@Deprecated +@Deprecated(since = "1.1.2.1", forRemoval = true) public record ZToolRule(T data, float speed, boolean correctForDrop) { } diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/resolvable/bukkit/ResolvablePlayerProfile.java b/API/src/main/java/fr/maxlego08/menu/api/utils/resolvable/bukkit/ResolvablePlayerProfile.java index 4fab3db35..b32d4b87f 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/resolvable/bukkit/ResolvablePlayerProfile.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/resolvable/bukkit/ResolvablePlayerProfile.java @@ -16,6 +16,10 @@ import java.net.URL; import java.util.UUID; +/** + * @deprecated not used by zMenu; the profile component reads its profile with {@link ResolvableProfileResolvable}. + */ +@Deprecated(since = "1.1.2.1", forRemoval = true) public final class ResolvablePlayerProfile implements Resolvable { private final @Nullable ResolvableUUID uuid; diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java b/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java index 857a630ae..a8c28731b 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java @@ -3,7 +3,6 @@ import fr.maxlego08.menu.api.annotations.*; import fr.maxlego08.menu.api.configuration.Configuration; import fr.maxlego08.menu.api.loader.ClassRegistry; -import fr.maxlego08.menu.api.utils.PlatformType; import fr.maxlego08.menu.api.utils.ReflectionsCache; import fr.maxlego08.menu.zcore.logger.Logger; import org.bukkit.Bukkit; @@ -19,10 +18,7 @@ private VersionFilter() { } public static boolean passes(@NotNull Class clazz) { - if (clazz.isAnnotationPresent(PaperOnly.class) && !PlatformType.isPaper()) { - return false; - } - if (clazz.isAnnotationPresent(SpigotOnly.class) && PlatformType.isPaper()) { + if (clazz.isAnnotationPresent(SpigotOnly.class)) { return false; } diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java new file mode 100644 index 000000000..fe33f0ee8 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java @@ -0,0 +1,107 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.order.SequentialSlotAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationSeed; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the parsing of the {@code play} option, for its default, and for deciding whether + * an animation may play from what the player has already seen. + */ +class AnimationPlayModeTest { + + private static final long HOUR = 3_600_000L; + private static final long NOW = 1_000_000_000L; + + @Test + void modesAreParsedIgnoringCaseAndSeparators() { + assertEquals(AnimationPlayMode.ALWAYS, AnimationPlayMode.parse("ALWAYS", AnimationPlayMode.ONCE)); + assertEquals(AnimationPlayMode.ONCE, AnimationPlayMode.parse("once", AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ONCE, AnimationPlayMode.parse("First-Open", AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ONCE, AnimationPlayMode.parse("FIRST_OPEN", AnimationPlayMode.ALWAYS)); + } + + @Test + void anUnknownOrMissingModeFallsBackToTheDefault() { + assertEquals(AnimationPlayMode.ALWAYS, AnimationPlayMode.parse(null, AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ALWAYS, AnimationPlayMode.parse("sometimes", AnimationPlayMode.ALWAYS)); + } + + @Test + void settingsPlayOnEveryOpenByDefault() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + + assertEquals(AnimationPlayMode.ALWAYS, new SlotAnimationSettings(defaults, true).play()); + assertEquals(AnimationPlayMode.ALWAYS, new SlotAnimationSettings(defaults, java.util.Map.of(), true).play()); + } + + @Test + void theModeThatSurvivesAReconnectIsParsedToo() { + assertEquals(AnimationPlayMode.ONCE_FOREVER, AnimationPlayMode.parse("ONCE_FOREVER", AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ONCE_FOREVER, AnimationPlayMode.parse("once-forever", AnimationPlayMode.ALWAYS)); + } + + @Test + void anAnimationNeverSeenAlwaysPlays() { + for (AnimationPlayMode mode : AnimationPlayMode.values()) { + assertTrue(mode.canPlay(null, NOW, 0), mode + " should play the first time"); + assertTrue(mode.canPlay(null, NOW, HOUR), mode + " should play the first time even under a cooldown"); + } + } + + @Test + void aOnceModeNeverPlaysTwice() { + assertFalse(AnimationPlayMode.ONCE.canPlay(NOW - 10 * HOUR, NOW, 0)); + assertFalse(AnimationPlayMode.ONCE_FOREVER.canPlay(NOW - 10 * HOUR, NOW, 0)); + assertFalse(AnimationPlayMode.ONCE_FOREVER.canPlay(NOW - 10 * HOUR, NOW, HOUR)); + } + + @Test + void alwaysReplaysUnlessItIsStillCoolingDown() { + AnimationPlayMode always = AnimationPlayMode.ALWAYS; + + assertTrue(always.canPlay(NOW - 10 * HOUR, NOW, 0), "no cooldown means no restriction"); + assertFalse(always.canPlay(NOW - HOUR / 2, NOW, HOUR), "half an hour into an hour long cooldown"); + assertTrue(always.canPlay(NOW - HOUR, NOW, HOUR), "the cooldown is over at exactly its length"); + assertTrue(always.canPlay(NOW - 2 * HOUR, NOW, HOUR)); + } + + @Test + void theModesKnowWhatTheyAre() { + assertTrue(AnimationPlayMode.ONCE_FOREVER.isPersistent()); + assertFalse(AnimationPlayMode.ONCE.isPersistent()); + assertFalse(AnimationPlayMode.ALWAYS.isPersistent()); + + assertTrue(AnimationPlayMode.ONCE.isOnce()); + assertTrue(AnimationPlayMode.ONCE_FOREVER.isOnce()); + assertFalse(AnimationPlayMode.ALWAYS.isOnce()); + } + + @Test + void settingsKnowWhenTheyNeedTheDatabase() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + + assertFalse(settings(defaults, AnimationPlayMode.ALWAYS, 0).needsPersistence()); + assertFalse(settings(defaults, AnimationPlayMode.ONCE, 0).needsPersistence()); + assertTrue(settings(defaults, AnimationPlayMode.ONCE_FOREVER, 0).needsPersistence()); + assertTrue(settings(defaults, AnimationPlayMode.ALWAYS, HOUR).needsPersistence()); + } + + @Test + void aNegativeCooldownIsNoCooldown() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + + assertEquals(0, settings(defaults, AnimationPlayMode.ALWAYS, -5).cooldown()); + } + + private static SlotAnimationSettings settings(SlotAnimationGroup defaults, AnimationPlayMode play, long cooldown) { + return new SlotAnimationSettings(defaults, java.util.Map.of(), true, play, java.util.List.of(), null, 100, + java.util.List.of(), AnimationSeed.RANDOM, java.util.List.of(), java.util.List.of(), cooldown); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java new file mode 100644 index 000000000..7f56e6970 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java @@ -0,0 +1,63 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.order.SlotGrid; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the mapping between the menu, the player inventory, and the single space an + * animation is laid out over. + */ +class AnimationSlotsTest { + + private static final int SIZE = 54; + + @Test + void theMenuComesFirstAndThePlayerInventoryAfterIt() { + assertEquals(90, AnimationSlots.totalSize(SIZE)); + assertFalse(AnimationSlots.isPlayer(SIZE, 53)); + assertTrue(AnimationSlots.isPlayer(SIZE, 54)); + } + + @Test + void theStorageRowsAreLaidOutBeforeTheHotbar() { + assertEquals(54, AnimationSlots.fromPlayer(SIZE, 9)); + assertEquals(62, AnimationSlots.fromPlayer(SIZE, 17)); + assertEquals(80, AnimationSlots.fromPlayer(SIZE, 35)); + + assertEquals(81, AnimationSlots.fromPlayer(SIZE, 0)); + assertEquals(89, AnimationSlots.fromPlayer(SIZE, 8)); + } + + @Test + void theMappingGoesBothWays() { + for (int playerSlot = 0; playerSlot < AnimationSlots.PLAYER_SIZE; playerSlot++) { + int slot = AnimationSlots.fromPlayer(SIZE, playerSlot); + assertEquals(playerSlot, AnimationSlots.toPlayer(SIZE, slot)); + } + } + + @Test + void everyPlayerSlotGetsItsOwnPlaceInTheAnimation() { + Set slots = new HashSet<>(); + for (int playerSlot = 0; playerSlot < AnimationSlots.PLAYER_SIZE; playerSlot++) { + assertTrue(slots.add(AnimationSlots.fromPlayer(SIZE, playerSlot))); + } + assertEquals(AnimationSlots.PLAYER_SIZE, slots.size()); + } + + @Test + void theCombinedSpaceStaysAGridSoTheGridOrdersKeepWorking() { + assertEquals(9, SlotGrid.width(AnimationSlots.totalSize(SIZE))); + assertEquals(10, SlotGrid.height(AnimationSlots.totalSize(SIZE))); + + assertEquals(9, SlotGrid.width(AnimationSlots.totalSize(27))); + assertEquals(7, SlotGrid.height(AnimationSlots.totalSize(27))); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java new file mode 100644 index 000000000..808684203 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java @@ -0,0 +1,53 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for the animation whose order is listed slot by slot. + */ +class CustomSlotAnimationTest { + + private static final int SIZE = 27; + private static final List ALL_SLOTS = IntStream.range(0, SIZE).boxed().toList(); + + @Test + void slotsAreRevealedInTheOrderTheyAreListed() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(13, 4, 22, 12, 14)); + + assertIterableEquals(List.of(13, 4, 22, 12, 14), animation.order(SIZE, List.of(4, 12, 13, 14, 22))); + } + + @Test + void slotsThatAreNotListedComeLast() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(13, 4)); + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(13, 4), ordered.subList(0, 2)); + assertIterableEquals(List.of(0, 1, 2, 3, 5, 6), ordered.subList(2, 8)); + } + + @Test + void aSlotListedTwiceKeepsItsFirstPlace() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(5, 3, 5, 1)); + + assertIterableEquals(List.of(5, 3, 1), animation.order(SIZE, List.of(1, 3, 5))); + } + + @Test + void everySlotIsReturnedExactlyOnce() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(26, 0, 13)); + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertEquals(SIZE, ordered.size()); + assertEquals(new HashSet<>(ALL_SLOTS), new HashSet<>(ordered)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java new file mode 100644 index 000000000..e29fb4c82 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java @@ -0,0 +1,67 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for the animation whose order is drawn in a matrix. Reading the matrix option is tested with its loader. + */ +class MatrixSlotAnimationTest { + + private static final int SIZE = 27; + private static final List ALL_SLOTS = IntStream.range(0, SIZE).boxed().toList(); + + @Test + void everyCharacterIsAStepPlayedInCharacterOrder() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of( + "111111111", + "122222221", + "111111111")); + + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26), ordered.subList(0, 20)); + assertIterableEquals(List.of(10, 11, 12, 13, 14, 15, 16), ordered.subList(20, 27)); + } + + @Test + void charactersSortNaturallySoDigitsComeBeforeLetters() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("a1b2")); + + assertIterableEquals(List.of(1, 3, 0, 2), animation.order(SIZE, List.of(0, 1, 2, 3))); + } + + @Test + void slotsTheMatrixDoesNotCoverComeLast() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("11 11")); + + List ordered = animation.order(SIZE, ALL_SLOTS); + assertIterableEquals(List.of(0, 1, 7, 8), ordered.subList(0, 4)); + assertIterableEquals(List.of(2, 3, 4, 5, 6, 9, 10), ordered.subList(4, 11)); + } + + @Test + void aRowLongerThanTheGridIsTruncated() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("1111111112222")); + + assertIterableEquals(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8), animation.order(SIZE, ALL_SLOTS).subList(0, 9)); + } + + @Test + void everySlotIsReturnedExactlyOnce() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("121212121", "212121212")); + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertEquals(SIZE, ordered.size()); + assertEquals(new HashSet<>(ALL_SLOTS), new HashSet<>(ordered)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java new file mode 100644 index 000000000..bed94df8f --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java @@ -0,0 +1,150 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Unit tests for the slot orderings used by the opening animation. They are pure arithmetic over a + * grid, so nothing here needs a server. + */ +class SlotAnimationTest { + + private static final int SIZE = 27; + private static final List ALL_SLOTS = IntStream.range(0, SIZE).boxed().toList(); + + @Test + void gridIsNineWideForEveryChestSize() { + assertEquals(9, SlotGrid.width(54)); + assertEquals(6, SlotGrid.height(54)); + assertEquals(9, SlotGrid.width(9)); + assertEquals(1, SlotGrid.height(9)); + } + + @Test + void gridFallsBackToASingleRowForOtherSizes() { + assertEquals(5, SlotGrid.width(5)); + assertEquals(1, SlotGrid.height(5)); + } + + @Test + void sequentialKeepsTheSlotOrder() { + assertIterableEquals(ALL_SLOTS, new SequentialSlotAnimation().order(SIZE, ALL_SLOTS)); + } + + @Test + void horizontalSnakeReversesEveryOtherRow() { + List expected = List.of( + 0, 1, 2, 3, 4, 5, 6, 7, 8, + 17, 16, 15, 14, 13, 12, 11, 10, 9, + 18, 19, 20, 21, 22, 23, 24, 25, 26); + assertIterableEquals(expected, new HorizontalSnakeSlotAnimation().order(SIZE, ALL_SLOTS)); + } + + @Test + void horizontalSnakeHandlesSparseSlots() { + assertIterableEquals(List.of(17, 9), new HorizontalSnakeSlotAnimation().order(SIZE, List.of(9, 17))); + } + + @Test + void verticalSnakeReversesEveryOtherColumn() { + List ordered = new VerticalSnakeSlotAnimation().order(SIZE, ALL_SLOTS); + assertIterableEquals(List.of(0, 9, 18, 19, 10, 1, 2, 11, 20), ordered.subList(0, 9)); + assertEquals(SIZE, ordered.size()); + } + + @Test + void spiralWalksTheGridClockwiseAndInwards() { + List expected = List.of( + 0, 1, 2, 3, 4, 5, 6, 7, 8, + 17, 26, + 25, 24, 23, 22, 21, 20, 19, 18, + 9, + 10, 11, 12, 13, 14, 15, 16); + assertIterableEquals(expected, new SpiralSlotAnimation().order(SIZE, ALL_SLOTS)); + } + + @Test + void fromCenterStartsAtTheMiddleSlot() { + List ordered = new FromCenterSlotAnimation().order(SIZE, ALL_SLOTS); + assertEquals(13, ordered.getFirst()); + assertEquals(new HashSet<>(List.of(0, 8, 18, 26)), new HashSet<>(ordered.subList(ordered.size() - 4, ordered.size()))); + } + + @Test + void waveSweepsAlongTheDiagonals() { + List ordered = new WaveSlotAnimation().order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 1, 9, 2, 10, 18), ordered.subList(0, 6)); + assertEquals(26, ordered.getLast()); + } + + @Test + void checkerboardFillsOneSlotOutOfTwoFirst() { + List ordered = new CheckerboardSlotAnimation().order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26), ordered.subList(0, 14)); + assertIterableEquals(List.of(1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25), ordered.subList(14, 27)); + } + + @Test + void columnsWalkEveryColumnInTheSameDirection() { + List ordered = new ColumnsSlotAnimation().order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 9, 18, 1, 10, 19, 2, 11, 20), ordered.subList(0, 9)); + } + + @Test + void rowsIsSequentialUnderAnotherName() { + assertIterableEquals(new SequentialSlotAnimation().order(SIZE, ALL_SLOTS), ALL_SLOTS); + } + + @Test + void everyAnimationReturnsEachSlotExactlyOnce() { + List sparse = List.of(0, 4, 8, 13, 18, 22, 26); + List animations = List.of( + new SequentialSlotAnimation(), + new HorizontalSnakeSlotAnimation(), + new VerticalSnakeSlotAnimation(), + new SpiralSlotAnimation(), + new FromCenterSlotAnimation(), + new WaveSlotAnimation(), + new CheckerboardSlotAnimation(), + new ColumnsSlotAnimation(), + new RandomSlotAnimation(new Random(42))); + + for (SlotAnimation animation : animations) { + List ordered = animation.order(SIZE, sparse); + assertEquals(sparse.size(), ordered.size(), animation.getClass().getSimpleName()); + assertEquals(new HashSet<>(sparse), new HashSet<>(ordered), animation.getClass().getSimpleName()); + } + } + + @Test + void groupSettingsClampNonSensicalValues() { + SlotAnimationGroup group = new SlotAnimationGroup(new SequentialSlotAnimation(), 0, 0, -5); + assertEquals(1, group.interval()); + assertEquals(1, group.slotsPerStep()); + assertEquals(0, group.delay()); + } + + @Test + void aGroupWithoutAnOverrideUsesTheDefaults() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + SlotAnimationGroup second = new SlotAnimationGroup(new SpiralSlotAnimation(), 10, 1, 0); + SlotAnimationSettings settings = new SlotAnimationSettings(defaults, java.util.Map.of(2, second), true); + + assertEquals(defaults, settings.group(1)); + assertEquals(second, settings.group(2)); + assertEquals(defaults, settings.group(7)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java new file mode 100644 index 000000000..da1925ddd --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java @@ -0,0 +1,83 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Unit tests for the anchors used to place things relative to the groups of an animation. + */ +class AnimationAnchorTest { + + @Test + void keywordsAreParsed() { + assertEquals(AnimationAnchor.START, AnimationAnchor.parse("start")); + assertEquals(AnimationAnchor.END, AnimationAnchor.parse("END")); + assertEquals(AnimationAnchor.START, AnimationAnchor.parse(" Start ")); + } + + @Test + void aPlainNumberIsATimeInMilliseconds() { + assertEquals(AnimationAnchor.at(250), AnimationAnchor.parse("250")); + assertEquals(AnimationAnchor.at(0), AnimationAnchor.parse("-40")); + } + + @Test + void groupBoundariesAreParsed() { + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parse("group:2")); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parse("group:2.end")); + assertEquals(AnimationAnchor.groupStart(10), AnimationAnchor.parse("GROUP:10")); + } + + @Test + void bothEndsOfAGroupCanBeWrittenExplicitly() { + assertEquals(AnimationAnchor.parse("group:2"), AnimationAnchor.parse("group:2.start")); + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parse("group:2.start")); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parse("GROUP:2.END")); + } + + @Test + void theExplicitSectionFormIsParsed() { + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parseValue(Map.of("group", 2))); + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parseValue(Map.of("group", 2, "at", "start"))); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parseValue(Map.of("group", 2, "at", "END"))); + assertEquals(AnimationAnchor.groupEnd(3), AnimationAnchor.parseValue(Map.of("group", "3", "at", "end"))); + } + + @Test + void withoutAGroupTheSectionFormAcceptsAnyCompactValue() { + assertEquals(AnimationAnchor.at(250), AnimationAnchor.parseValue(Map.of("at", 250))); + assertEquals(AnimationAnchor.START, AnimationAnchor.parseValue(Map.of("at", "start"))); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parseValue(Map.of("at", "group:2.end"))); + } + + @Test + void bothFormsAgree() { + assertEquals(AnimationAnchor.parse("group:2.end"), AnimationAnchor.parseValue(Map.of("group", 2, "at", "end"))); + assertEquals(AnimationAnchor.parse("250"), AnimationAnchor.parseValue(250)); + assertEquals(AnimationAnchor.parse("end"), AnimationAnchor.parseValue("end")); + } + + @Test + void anInvalidSectionIsRejectedRatherThanGuessed() { + assertNull(AnimationAnchor.parseValue(null)); + assertNull(AnimationAnchor.parseValue(Map.of())); + assertNull(AnimationAnchor.parseValue(Map.of("group", "x"))); + assertNull(AnimationAnchor.parseValue(Map.of("group", 2, "at", "middle"))); + assertNull(AnimationAnchor.parseValue(Map.of("at", "soon"))); + } + + @Test + void anInvalidAnchorIsRejectedRatherThanGuessed() { + assertNull(AnimationAnchor.parse(null)); + assertNull(AnimationAnchor.parse("")); + assertNull(AnimationAnchor.parse(" ")); + assertNull(AnimationAnchor.parse("group:x")); + assertNull(AnimationAnchor.parse("group:")); + assertNull(AnimationAnchor.parse("group:2.middle")); + assertNull(AnimationAnchor.parse("soon")); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java new file mode 100644 index 000000000..f7acb6663 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java @@ -0,0 +1,80 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.animation.slot.order.SequentialSlotAnimation; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * Unit tests for where the randomness of an animation comes from. + */ +class AnimationSeedTest { + + private static final UUID PLAYER = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID OTHER = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + + @Test + void theFormsAreParsed() { + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse(null)); + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse("random")); + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse(" ")); + assertEquals(AnimationSeed.Type.PLAYER, AnimationSeed.parse("PLAYER").type()); + assertEquals(new AnimationSeed(AnimationSeed.Type.FIXED, 42), AnimationSeed.parse("42")); + } + + @Test + void anythingElseRollsAfresh() { + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse("sometimes")); + } + + @Test + void aPlayerAlwaysGetsTheSameAnimation() { + AnimationSeed seed = AnimationSeed.parse("player"); + + assertEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(PLAYER, "pulse"))); + } + + @Test + void twoPlayersGetDifferentOnes() { + AnimationSeed seed = AnimationSeed.parse("player"); + + assertNotEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(OTHER, "pulse"))); + } + + @Test + void twoAnimationsDoNotEndUpWithTheSamePattern() { + AnimationSeed seed = AnimationSeed.parse("player"); + + assertNotEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(PLAYER, "sparkle"))); + } + + @Test + void aFixedSeedIsTheSameForEveryone() { + AnimationSeed seed = AnimationSeed.parse("7"); + + assertEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(OTHER, "pulse"))); + } + + @Test + void aPlayerSeedFallsBackWhenThereIsNoPlayer() { + AnimationSeed seed = AnimationSeed.parse("player"); + assertEquals(10, draw(seed.create(null, "pulse")).size()); + } + + @Test + void anAnimationRollsAfreshByDefault() { + SlotAnimationSettings settings = new SlotAnimationSettings( + new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0), true); + + assertEquals(AnimationSeed.RANDOM, settings.seed()); + } + + private static List draw(Random random) { + return java.util.stream.IntStream.range(0, 10).map(index -> random.nextInt(1000)).boxed().toList(); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java new file mode 100644 index 000000000..2fb8e4224 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java @@ -0,0 +1,111 @@ +package fr.maxlego08.menu.api.animation.slot.settings.idle; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.TestMenuItemStacks; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the animation a button plays on its own slots while the menu stays open. + */ +class ButtonIdleAnimationTest { + + private static final MenuItemStack FIRST = TestMenuItemStacks.named("first"); + private static final MenuItemStack SECOND = TestMenuItemStacks.named("second"); + + @Test + void aLoopingCycleNeverEnds() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, true); + + assertSame(FIRST, animation.item(0)); + assertSame(SECOND, animation.item(1)); + assertSame(FIRST, animation.item(2)); + assertSame(SECOND, animation.item(101)); + + assertFalse(animation.isOver(0)); + assertFalse(animation.isOver(1000)); + } + + @Test + void aCycleThatDoesNotLoopIsOverAfterItsLastItem() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, false); + + assertFalse(animation.isOver(0)); + assertFalse(animation.isOver(1)); + assertTrue(animation.isOver(2)); + } + + @Test + void theIntervalIsClamped() { + assertEquals(1, ButtonIdleAnimation.of(List.of(FIRST), 0, true).interval()); + assertEquals(1, ButtonIdleAnimation.of(List.of(FIRST), -50, true).interval()); + } + + @Test + void aRandomCycleIgnoresTheTurnAndPicks() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, true, true); + Random random = new Random(4); + + Set seen = new HashSet<>(); + for (int turn = 0; turn < 50; turn++) { + seen.add(animation.item(0, random)); + } + assertEquals(2, seen.size()); + } + + @Test + void aCycleThatIsNotRandomStillFollowsItsOrder() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, true, false); + Random random = new Random(4); + + assertSame(FIRST, animation.item(0, random)); + assertSame(SECOND, animation.item(1, random)); + assertSame(FIRST, animation.item(2, random)); + } + + @Test + void aFrameWithoutADurationFallsBackToTheInterval() { + IdleFrame held = new IdleFrame(FIRST, 1000, null); + IdleFrame plain = new IdleFrame(SECOND); + + assertEquals(1000, held.duration(200)); + assertEquals(200, plain.duration(200)); + } + + @Test + void aNegativeDurationIsTreatedAsNone() { + assertEquals(0, new IdleFrame(FIRST, -50, null).duration()); + assertEquals(200, new IdleFrame(FIRST, -50, null).duration(200)); + } + + @Test + void framesKeepTheirOwnDurationAndSound() { + IdleFrame held = new IdleFrame(FIRST, 1000, null); + IdleFrame flash = new IdleFrame(SECOND, 50, null); + ButtonIdleAnimation animation = new ButtonIdleAnimation(List.of(held, flash), 200, true); + + assertSame(held, animation.frame(0)); + assertSame(flash, animation.frame(1)); + assertSame(held, animation.frame(2)); + assertSame(FIRST, animation.item(0)); + assertIterableEquals(List.of(FIRST, SECOND), animation.items()); + } + + @Test + void aSingleItemCycleAlwaysShowsIt() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST), 200, true); + + assertSame(FIRST, animation.item(0)); + assertSame(FIRST, animation.item(7)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java b/API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java new file mode 100644 index 000000000..539d83718 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java @@ -0,0 +1,80 @@ +package fr.maxlego08.menu.api.button; + +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.button.buttons.ItemDragButton; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the class level refusal of temporary items. + */ +class ButtonTemporaryItemsTest { + + @Test + void aButtonRefusesToBeCoveredByDefault() { + Button button = new PlainButton(); + + assertFalse(button.allowsTemporaryItems()); + assertFalse(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void aDragButtonOptsIn() { + Button button = new ItemDragButton() { + }; + + assertTrue(button.allowsTemporaryItems()); + assertTrue(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void aButtonBuiltInCodeFollowsItsOwnClass() { + assertFalse(new PlainButton().getAnimationSettings().allowTemporaryItems()); + assertTrue(new CoverableButton().getAnimationSettings().allowTemporaryItems()); + } + + @Test + void theConfigurationMayAllowAButtonThatWouldHaveRefused() { + Button button = new PlainButton(); + button.setAnimationSettings(new ButtonAnimationSettings(true, 1, 0, null, true)); + + assertTrue(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void theConfigurationMayRefuseAButtonThatWouldHaveAllowed() { + Button button = new CoverableButton(); + button.setAnimationSettings(new ButtonAnimationSettings(true, 1, 0, null, false)); + + assertTrue(button.allowsTemporaryItems()); + assertFalse(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void theOtherAnimationSettingsAreUntouched() { + Button button = new PlainButton(); + button.setAnimationSettings(new ButtonAnimationSettings(true, 3, 250, null, true)); + + ButtonAnimationSettings settings = button.getAnimationSettings(); + assertTrue(settings.allowTemporaryItems()); + assertTrue(settings.enabled()); + assertEquals(3, settings.group()); + assertEquals(250, settings.delay()); + } + + private static final class PlainButton extends Button { + } + + /** + * A button that is safe to draw over, and says so. + */ + private static final class CoverableButton extends Button { + @Override + public boolean allowsTemporaryItems() { + return true; + } + } +} diff --git a/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java new file mode 100644 index 000000000..b657f6082 --- /dev/null +++ b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java @@ -0,0 +1,27 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.MenuItemStack; + +import java.lang.reflect.Proxy; + +/** + * Creates {@link MenuItemStack} stand-ins for the compiler tests. The compiler only ever carries + * them around, it never builds them, so a proxy that answers nothing is enough. + */ +public final class TestMenuItemStacks { + + private TestMenuItemStacks() { + } + + public static MenuItemStack named(String name) { + return (MenuItemStack) Proxy.newProxyInstance( + TestMenuItemStacks.class.getClassLoader(), + new Class[]{MenuItemStack.class}, + (proxy, method, args) -> switch (method.getName()) { + case "toString" -> name; + case "hashCode" -> name.hashCode(); + case "equals" -> proxy == args[0]; + default -> null; + }); + } +} diff --git a/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java new file mode 100644 index 000000000..b3c72b2d6 --- /dev/null +++ b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java @@ -0,0 +1,32 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.sound.SoundOption; + +import java.lang.reflect.Proxy; + +/** + * A {@link SoundOption} stand-in for the compiler tests, which only ever carry it around. + */ +public final class TestSounds { + + public static final SoundOption SILENT = named("silent"); + + public static SoundOption other() { + return named("other"); + } + + public static SoundOption named(String name) { + return (SoundOption) Proxy.newProxyInstance( + TestSounds.class.getClassLoader(), + new Class[]{SoundOption.class}, + (proxy, method, args) -> switch (method.getName()) { + case "toString" -> name; + case "hashCode" -> name.hashCode(); + case "equals" -> proxy == args[0]; + default -> null; + }); + } + + private TestSounds() { + } +} diff --git a/Common/build.gradle.kts b/Common/build.gradle.kts index 4ac613b09..f40c16387 100644 --- a/Common/build.gradle.kts +++ b/Common/build.gradle.kts @@ -1,6 +1,6 @@ dependencies { api(projects.api) - api(projects.nms.base) compileOnly(libs.paper.api) testImplementation(libs.paper.api) + testImplementation(testFixtures(projects.api)) } diff --git a/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java b/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java index 6b6be0da1..bd964105c 100644 --- a/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java +++ b/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java @@ -3,6 +3,9 @@ import com.google.common.collect.ArrayListMultimap; import fr.maxlego08.menu.api.InventoryManager; import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.ZMenuKeys; +import fr.maxlego08.menu.api.registry.Registries; +import net.kyori.adventure.key.Key; import fr.maxlego08.menu.api.attribute.AttributeMergeStrategy; import fr.maxlego08.menu.api.attribute.AttributeUtil; import fr.maxlego08.menu.api.attribute.AttributeWrapper; @@ -12,12 +15,10 @@ import fr.maxlego08.menu.api.enchantment.Enchantments; import fr.maxlego08.menu.api.enums.AmountType; import fr.maxlego08.menu.api.enums.MenuItemRarity; -import fr.maxlego08.menu.api.exceptions.ItemEnchantException; import fr.maxlego08.menu.api.font.FontImage; import fr.maxlego08.menu.api.itemstack.*; import fr.maxlego08.menu.api.loader.MaterialLoader; import fr.maxlego08.menu.api.utils.LoreType; -import fr.maxlego08.menu.api.utils.MapConfiguration; import fr.maxlego08.menu.api.utils.OfflinePlayerCache; import fr.maxlego08.menu.api.utils.Placeholders; import fr.maxlego08.menu.api.utils.resolvable.lang.ResolvableInt; @@ -28,7 +29,6 @@ import fr.maxlego08.menu.zcore.utils.PerformanceDebug; import org.bukkit.*; import org.bukkit.block.banner.Pattern; -import org.bukkit.block.banner.PatternType; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; @@ -36,7 +36,6 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.*; import org.bukkit.inventory.meta.trim.ArmorTrim; -import org.bukkit.potion.PotionType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jspecify.annotations.NonNull; @@ -230,11 +229,6 @@ private ItemStack createItemStack(Player player, Placeholders placeholders, Offl private Material resolveMaterial(Player player, Placeholders placeholders, OfflinePlayer offlinePlayer) { String papiMaterial = this.papi(placeholders.parse(this.material), offlinePlayer == null ? player : offlinePlayer, true); - try { - return this.getMaterial(Integer.parseInt(papiMaterial)); - } catch (Exception ignored) { - } - try { return papiMaterial != null ? Material.getMaterial(papiMaterial.toUpperCase(Locale.ROOT)) : null; } catch (Exception ignored) { @@ -254,7 +248,7 @@ private ItemStack resolveCustomItem(Player player, Placeholders placeholders, Ma String key = values[0]; String value = values[1]; - Optional optional = this.inventoryManager.getMaterialLoader(key); + Optional optional = this.findMaterialLoader(key); if (optional.isPresent()) { MaterialLoader loader = optional.get(); return loader.load(player, null, this.path, value); @@ -310,7 +304,7 @@ private void applyItemMeta(Player player, Placeholders placeholders, OfflinePlay } Material finalMaterial = itemStack.getType(); String locale = this.findPlayerLocale(player); - FontImage fontImage = this.inventoryManager.getFontImage(); + FontImage fontImage = this.inventoryManager.getPlugin().getFontImage(); this.applyDisplayNameLore(player, placeholders, itemMeta, offlinePlayer, locale, fontImage, useCache); this.applyGlowing(itemMeta); @@ -327,7 +321,7 @@ private void applyGlowing(ItemMeta itemMeta) { return; } - Enchantments helperEnchantments = this.inventoryManager.getEnchantments(); + Enchantments helperEnchantments = this.inventoryManager.getPlugin().getEnchantments(); helperEnchantments.getEnchantments("power").ifPresent(menuEnchantment -> { itemMeta.addEnchant(menuEnchantment.enchantment(), 1, true); itemMeta.addItemFlags(ItemFlag.HIDE_ENCHANTS); @@ -421,11 +415,11 @@ private void applyDisplayNameLore(Player player, Placeholders placeholders, Item } if (itemName != null) { - this.inventoryManager.getMeta().updateDisplayName(itemMeta, itemName, player); + this.inventoryManager.getPlugin().getMetaUpdater().updateDisplayName(itemMeta, itemName, player); } if (!itemLore.isEmpty()) { - this.inventoryManager.getMeta().updateLore(itemMeta, itemLore, this.loreType); + this.inventoryManager.getPlugin().getMetaUpdater().updateLore(itemMeta, itemLore, this.loreType); } } @@ -916,164 +910,6 @@ private void updatePlaceholder(String string) { this.needPlaceholderAPI = string.contains("%"); } - public void setTypeMapAccessor(MapConfiguration configuration) { - - this.setData(configuration.getString("data", "0")); - this.setDurability(configuration.getString("durability", null)); - this.setAmount(configuration.getString("amount", "1")); - this.setAmountType(AmountType.valueOf(configuration.getString("amount-type", "SET").toUpperCase(Locale.ROOT))); - this.setMaterial(configuration.getString("material", null)); - this.setTargetPlayer(configuration.getString("target", null)); - this.setUrl(configuration.getString("url", null)); - - Color potionColor = this.getColor(configuration, "color", null); - - try { - Material material = Material.valueOf(configuration.getString("material", "").toUpperCase(Locale.ROOT)); - String materialName = material.toString(); - if (materialName.startsWith("LEATHER_")) { - Color armorColor = this.getColor(configuration, "color", Color.fromRGB(160, 101, 64)); - String type = materialName.replace("LEATHER_", ""); - this.setLeatherArmor(new LeatherArmor(LeatherArmor.ArmorType.valueOf(type), armorColor)); - } - } catch (Exception ignored) { - } - - if (configuration.contains("potion")) { - PotionType type = PotionType.valueOf(configuration.getString("potion", "REGEN").toUpperCase(Locale.ROOT)); - int level = configuration.getInt("level", 1); - boolean splash = configuration.getBoolean("splash", false); - boolean extended = configuration.getBoolean("extended", false); - boolean arrow = configuration.getBoolean("arrow", false); - - Potion potion = new Potion(type, level, splash, extended, arrow); - potion.setColor(potionColor); - this.setPotion(potion); - } - - if (configuration.contains("banner")) { - - DyeColor dyeColor = DyeColor.valueOf(configuration.getString("banner", "WHITE").toUpperCase(Locale.ROOT)); - List stringPattern = configuration.getStringList("patterns"); - List patterns = new ArrayList<>(); - for (String string : stringPattern) { - String[] split = string.split(":"); - if (split.length != 2) continue; - patterns.add(new Pattern(DyeColor.valueOf(split[0]), PatternType.valueOf(split[1]))); - } - this.setBanner(new Banner(dyeColor, patterns)); - - } - - /*if (configuration.contains("firework")) { - - ConfigurationSection section = configuration.getConfigurationSection("firework"); - if (section != null) { - boolean isStar = section.getBoolean("star", false); - FireworkEffect.Builder builder = FireworkEffect.builder(); - builder.flicker(section.getBoolean("flicker")); - builder.trail(section.getBoolean("trail")); - builder.with(FireworkEffect.Type.valueOf(section.getString("type", "BALL"))); - builder.withColor(getColors(section, "colors")); - builder.withFade(getColors(section, "fadeColors")); - setFirework(new Firework(isStar, builder.build())); - } - }*/ - - List lore = configuration.getStringList("lore"); - if (lore.isEmpty()) { - Object object = configuration.getObject("lore", null); - if (object instanceof String loreString) { - lore = Arrays.asList(loreString.split("\n")); - } - } - this.setLore(lore); - this.setDisplayName(configuration.getString("name", null)); - this.setGlowing(configuration.getBoolean("glow")); - this.setModelID(configuration.getString("modelID", configuration.getString("model-id", configuration.getString("modelId", configuration.getString("customModelId", configuration.getString("customModelData", "0")))))); - - List enchants = configuration.getStringList("enchants"); - Map enchantments = new HashMap<>(); - - for (String enchantString : enchants) { - - try { - - String[] splitEnchant = enchantString.split(","); - - if (splitEnchant.length == 1) throw new ItemEnchantException("an error occurred while loading the enchantment " + enchantString); - - int level; - String enchant = splitEnchant[0]; - try { - level = Integer.parseInt(splitEnchant[1]); - } catch (NumberFormatException e) { - throw new ItemEnchantException("an error occurred while loading the enchantment " + enchantString); - } - - Enchantment enchantment = Enchantment.getByName(enchant); - if (enchantment == null) { - throw new ItemEnchantException("an error occurred while loading the enchantment " + enchantString); - } - - enchantments.put(enchantment, level); - - } catch (ItemEnchantException e) { - Logger.error(e); - } - } - - List flagStrings = configuration.getStringList("flags"); - List flags = new ArrayList<>(flagStrings.size()); - for (String flag : flagStrings) { - flags.add(this.getFlag(flag)); - } - - List attributeModifiers = new ArrayList<>(); - - if (configuration.contains("attributes")) { - List> attributesFromConfig = (List>) configuration.getList("attributes"); - if (attributesFromConfig != null) { - for (Map attributeMap : attributesFromConfig) { - attributeModifiers.add(AttributeWrapper.deserialize(attributeMap)); - } - } - } - - this.setEnchantments(enchantments); - this.setFlags(flags); - this.setAttributes(attributeModifiers); - this.setAttributeMergeStrategy(AttributeMergeStrategy.valueOf(configuration.getString("attribute-merge-strategy", "REPLACE").toUpperCase(Locale.ROOT))); - } - - private Color getColor(MapConfiguration configuration, String key, Color def) { - String[] split = configuration.getString(key, "").split(","); - - if (split.length == 3) { - return Color.fromRGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2])); - } else if (split.length == 4) { - return Color.fromARGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2]), Integer.parseInt(split[3])); - } - - return def; - } - - private List getColors(MapConfiguration section, String key) { - List colors = new ArrayList<>(); - - for (String color : section.getStringList(key)) { - String[] split = color.split(","); - - if (split.length == 3) { - colors.add(Color.fromRGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2]))); - } else if (split.length == 4) { - colors.add(Color.fromARGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2]), Integer.parseInt(split[3]))); - } - } - - return colors; - } - public Map getTranslatedDisplayName() { return this.translatedDisplayName; } @@ -1278,12 +1114,17 @@ public boolean isDynamicMaterial() { if (this.material.contains(":")) { String[] values = this.material.split(":", 2); if (values.length == 2) { - return this.inventoryManager.getMaterialLoader(values[0]).isPresent(); + return this.findMaterialLoader(values[0]).isPresent(); } } return false; } + private Optional findMaterialLoader(String prefix) { + Key key = ZMenuKeys.tryParse(prefix); + return key == null ? Optional.empty() : this.inventoryManager.getRegistries().registry(Registries.MATERIAL_LOADER).get(key); + } + @Override public void serialize(@NotNull ConfigurationSection section) { @@ -1440,7 +1281,7 @@ private void serializeTranslations(ConfigurationSection section) { private String enchantmentName(Enchantment enchantment) { String key = enchantment.getKey().getKey(); - boolean readsBack = this.inventoryManager.getEnchantments().getEnchantments(key) + boolean readsBack = this.inventoryManager.getPlugin().getEnchantments().getEnchantments(key) .map(menuEnchantment -> menuEnchantment.enchantment().equals(enchantment)) .orElse(false); return readsBack ? key : enchantment.getName(); diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java new file mode 100644 index 000000000..2db283aaf --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java @@ -0,0 +1,369 @@ +package fr.maxlego08.menu.animation.compile; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.AnimationContext; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.item.AnimationKeyframe; +import fr.maxlego08.menu.api.animation.slot.settings.item.TemporaryItem; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Turns the animation configuration of an inventory into a flat, sorted list of {@link AnimationStep}. + * + *

Compilation happens in two passes. The first one lays the groups out one after the other and + * records where each of them starts and ends. The second one resolves the {@link AnimationAnchor} of + * everything that is positioned relative to those boundaries. All of the timing is therefore decided + * before anything is displayed, and the engine only has to apply the steps whose time has come.

+ * + *

Steps carry an absolute time rather than a delay from the previous one, so the passes never + * have to know about each other: whatever they produce is merged by sorting.

+ */ +public final class AnimationCompiler { + + private AnimationCompiler() { + } + + /** + * Compiles the animation of an inventory. + * + * @param inventorySize the size of the inventory + * @param settings the animation settings of the inventory + * @param filledSlots the slots holding an item, in ascending order + * @param slotSettings the per slot configuration, slots missing from the map use + * {@link ButtonAnimationSettings#DEFAULT} + * @return the compiled animation, empty if nothing is animated + */ + @NotNull + public static CompiledAnimation compile(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings) { + return compile(inventorySize, settings, filledSlots, slotSettings, AnimationPlayback.REVEAL); + } + + /** + * Compiles the animation of an inventory for a given way of playing it. + * + * @param inventorySize the size of the inventory + * @param settings the animation settings of the inventory + * @param filledSlots the slots holding an item, in ascending order + * @param slotSettings the per slot configuration, slots missing from the map use + * {@link ButtonAnimationSettings#DEFAULT} + * @param playback whether the buttons are revealed, or only drawn over + * @return the compiled animation, empty if nothing is animated + */ + @NotNull + public static CompiledAnimation compile(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings, + @NotNull AnimationPlayback playback) { + return compile(inventorySize, settings, filledSlots, slotSettings, playback, null); + } + + /** + * Compiles the animation of an inventory, telling the orderings who it is played for. + * + * @param inventorySize the size of the inventory + * @param settings the animation settings of the inventory + * @param filledSlots the slots holding an item, in ascending order + * @param slotSettings the per slot configuration, slots missing from the map use + * {@link ButtonAnimationSettings#DEFAULT} + * @param playback whether the buttons are revealed, or only drawn over + * @param context the player and the menu the animation is played for, null outside of one + * @return the compiled animation, empty if nothing is animated + */ + @NotNull + public static CompiledAnimation compile(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings, + @NotNull AnimationPlayback playback, + @Nullable AnimationContext context) { + + AnimationContext animationContext = context == null + ? new AnimationContext(inventorySize, filledSlots, null, null) + : context; + + List steps = new ArrayList<>(); + Map revealTimes = new LinkedHashMap<>(); + AnimationTiming timing = revealButtons(inventorySize, settings, filledSlots, slotSettings, steps, revealTimes, animationContext, playback); + + if (playback == AnimationPlayback.OVERLAY) steps.clear(); + + if (playback == AnimationPlayback.CONCEAL) { + steps.replaceAll(step -> step.action() == StepAction.REVEAL + ? new AnimationStep(step.time(), step.slot(), StepAction.CLEAR, null, step.sound(), step.useCache()) + : step); + revealTimes.clear(); + } + + applyTemporaryItems(inventorySize, settings, timing, steps, revealTimes, slotSettings, playback, animationContext); + + if (steps.isEmpty()) return CompiledAnimation.EMPTY; + + steps.sort(Comparator.comparingLong(AnimationStep::time).thenComparingInt(AnimationStep::slot)); + return new CompiledAnimation(dedupeSounds(steps), timing); + } + + /** + * First pass: lays the groups out and emits one {@link StepAction#REVEAL} per animated slot. + * + * @return where every group starts and ends + */ + @NotNull + private static AnimationTiming revealButtons(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings, + @NotNull List steps, + @NotNull Map revealTimes, + @NotNull AnimationContext context, + @NotNull AnimationPlayback playback) { + + boolean honourOptOut = playback != AnimationPlayback.CONCEAL; + + Map> slotsByGroup = new TreeMap<>(); + for (int slot : filledSlots) { + ButtonAnimationSettings slotSetting = slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT); + if (honourOptOut && !slotSetting.enabled()) continue; + slotsByGroup.computeIfAbsent(slotSetting.group(), key -> new ArrayList<>()).add(slot); + } + + Map groups = new LinkedHashMap<>(); + long groupStart = 0; + + for (Map.Entry> entry : slotsByGroup.entrySet()) { + SlotAnimationGroup group = settings.group(entry.getKey()); + List ordered = new ArrayList<>(group.animation().order(context.withSlots(entry.getValue()))); + if (group.reverse()) Collections.reverse(ordered); + + long start = groupStart + group.delay(); + long groupEnd = start; + + for (int index = 0; index < ordered.size(); index++) { + int slot = ordered.get(index); + long buttonDelay = slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT).delay(); + long time = start + ((long) (index / group.slotsPerStep()) * group.interval()) + buttonDelay; + if (group.jitter() > 0) { + time = Math.max(0, time + context.random().nextLong(-group.jitter(), group.jitter() + 1)); + } + + ButtonAnimationSettings slotSetting = slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT); + SoundOption sound = slotSetting.sound() != null ? slotSetting.sound() : group.sound(); + + steps.add(new AnimationStep(time, slot, StepAction.REVEAL, null, sound)); + revealTimes.put(slot, time); + groupEnd = Math.max(groupEnd, time + group.interval()); + } + + groups.put(entry.getKey(), new GroupTiming(start, groupEnd)); + + groupStart = groupEnd; + } + + return new AnimationTiming(groups, groupStart); + } + + /** + * Second pass: places the items that only exist while the animation plays. + * + *

Every temporary item is a list of keyframes. A keyframe holding an item displays it from + * then on, a keyframe without one removes it. An item still shown when the last group is over is + * removed there, so an animation can never leak an item into the finished menu.

+ * + *

Items are resolved per slot rather than one after the other, because several of them may + * cover the same slot: at any moment the one declared last wins, and a step is only emitted when + * what the slot displays actually changes.

+ * + *

A temporary item wins over the button owning its slot for as long as it is shown. When the + * last of them goes away the slot is not blanked but re-resolved: it goes back to the real item + * if the button would have been revealed by then, and is emptied otherwise. Because the reveal + * times are already known, that decision is taken here rather than at playback.

+ */ + private static void applyTemporaryItems(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull AnimationTiming timing, + @NotNull List steps, + @NotNull Map revealTimes, + @NotNull Map slotSettings, + @NotNull AnimationPlayback playback, + @NotNull AnimationContext context) { + + if (settings.temporaryItems().isEmpty()) return; + + Map> keyframesBySlot = new TreeMap<>(); + + for (int index = 0; index < settings.temporaryItems().size(); index++) { + TemporaryItem temporaryItem = settings.temporaryItems().get(index); + if (temporaryItem.frames().isEmpty()) continue; + + boolean perSlot = temporaryItem.chancePerSlot(); + if (!perSlot && !context.roll(temporaryItem.chance())) continue; + + List shared = perSlot ? null : resolveKeyframes(temporaryItem, timing, context); + if (shared != null && shared.isEmpty()) continue; + + List eligible = new ArrayList<>(); + for (int slot : temporaryItem.slots()) { + if (slot < 0 || slot >= inventorySize) continue; + if (!slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT).allowTemporaryItems()) continue; + eligible.add(slot); + } + + for (int slot : context.pickSome(eligible, temporaryItem.count())) { + if (perSlot && !context.roll(temporaryItem.chance())) continue; + + List frames = shared != null ? shared : resolveKeyframes(temporaryItem, timing, context); + if (frames.isEmpty()) continue; + + List slotKeyframes = keyframesBySlot.computeIfAbsent(slot, key -> new ArrayList<>()); + for (ResolvedKeyframe frame : frames) { + slotKeyframes.add(new SlotKeyframe(frame.time(), index, frame.item(), frame.sound(), temporaryItem.useCache())); + } + } + } + if (keyframesBySlot.isEmpty()) return; + + dropRepeatedSounds(keyframesBySlot); + + List temporarySteps = new ArrayList<>(); + Set hiddenReveals = new HashSet<>(); + + keyframesBySlot.forEach((slot, keyframes) -> { + keyframes.sort(Comparator.comparingLong(SlotKeyframe::time)); + + TreeMap shown = new TreeMap<>(); + Map shownUseCache = new TreeMap<>(); + MenuItemStack displayed = null; + long coverStart = 0; + Long reveal = revealTimes.get(slot); + + for (int index = 0; index < keyframes.size(); index++) { + SlotKeyframe keyframe = keyframes.get(index); + + if (keyframe.item() == null) { + shown.remove(keyframe.owner()); + shownUseCache.remove(keyframe.owner()); + } else { + shown.put(keyframe.owner(), keyframe.item()); + shownUseCache.put(keyframe.owner(), keyframe.useCache()); + } + + if (index + 1 < keyframes.size() && keyframes.get(index + 1).time() == keyframe.time()) continue; + + Integer topOwner = shown.isEmpty() ? null : shown.lastKey(); + MenuItemStack top = topOwner == null ? null : shown.get(topOwner); + if (top == displayed) continue; + + if (top != null) { + if (displayed == null) coverStart = keyframe.time(); + boolean useCache = shownUseCache.getOrDefault(topOwner, true); + temporarySteps.add(new AnimationStep(keyframe.time(), slot, StepAction.SET, top, keyframe.sound(), useCache)); + } else { + if (reveal != null && reveal >= coverStart && reveal <= keyframe.time()) hiddenReveals.add(slot); + StepAction action = playback == AnimationPlayback.OVERLAY || (reveal != null && reveal <= keyframe.time()) + ? StepAction.REVEAL + : StepAction.CLEAR; + temporarySteps.add(new AnimationStep(keyframe.time(), slot, action, null, keyframe.sound())); + } + displayed = top; + } + }); + + if (!hiddenReveals.isEmpty()) { + steps.removeIf(step -> step.action() == StepAction.REVEAL && hiddenReveals.contains(step.slot())); + } + steps.addAll(temporarySteps); + } + + /** + * Keeps a keyframe sound on the first slot it lands on only, so an item covering nine slots does + * not play the same sound nine times. + */ + private static void dropRepeatedSounds(@NotNull Map> keyframesBySlot) { + Set seen = new HashSet<>(); + + keyframesBySlot.values().forEach(keyframes -> keyframes.replaceAll(keyframe -> { + if (keyframe.sound() == null) return keyframe; + return seen.add(keyframe.owner() + "@" + keyframe.time()) + ? keyframe + : new SlotKeyframe(keyframe.time(), keyframe.owner(), keyframe.item(), null, keyframe.useCache()); + })); + } + + /** + * A keyframe of one temporary item, applied to one slot. {@code owner} is the position of the + * item in the configuration, which decides who wins when two of them cover the same slot. + */ + private record SlotKeyframe(long time, int owner, MenuItemStack item, SoundOption sound, boolean useCache) { + } + + /** + * Resolves the keyframes of a temporary item to times, in the order they happen, and appends the + * removal of an item that is never removed explicitly. + */ + @NotNull + private static List resolveKeyframes(@NotNull TemporaryItem temporaryItem, @NotNull AnimationTiming timing, + @NotNull AnimationContext context) { + List frames = new ArrayList<>(temporaryItem.frames().size() + 1); + for (AnimationKeyframe keyframe : temporaryItem.frames()) { + if (!keyframe.isRemoval() && !context.roll(keyframe.chance())) continue; + + MenuItemStack item = keyframe.choices().isEmpty() ? keyframe.item() : context.pick(keyframe.choices()); + frames.add(new ResolvedKeyframe(timing.resolve(keyframe.at()), item, keyframe.sound())); + } + if (frames.isEmpty()) return frames; + + frames.sort(Comparator.comparingLong(ResolvedKeyframe::time)); + + ResolvedKeyframe last = frames.getLast(); + if (last.item() != null) { + frames.add(new ResolvedKeyframe(Math.max(timing.end(), last.time()), null, null)); + } + return frames; + } + + private record ResolvedKeyframe(long time, MenuItemStack item, SoundOption sound) { + } + + /** + * Keeps at most one sound per instant. + * + *

A step revealing nine slots at once, or a keyframe landing on the same instant as a reveal, + * would otherwise play the same sound several times in the same tick, which is heard as noise + * rather than as an effect.

+ */ + @NotNull + private static List dedupeSounds(@NotNull List steps) { + List deduped = new ArrayList<>(steps.size()); + + long currentTime = Long.MIN_VALUE; + boolean playedAtCurrentTime = false; + + for (AnimationStep step : steps) { + if (step.time() != currentTime) { + currentTime = step.time(); + playedAtCurrentTime = false; + } + + if (step.sound() == null) { + deduped.add(step); + } else if (playedAtCurrentTime) { + deduped.add(new AnimationStep(step.time(), step.slot(), step.action(), step.item(), null, step.useCache())); + } else { + deduped.add(step); + playedAtCurrentTime = true; + } + } + return deduped; + } +} \ No newline at end of file diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java new file mode 100644 index 000000000..d65fc694e --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java @@ -0,0 +1,42 @@ +package fr.maxlego08.menu.animation.compile; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * One thing the opening animation does, at one point in time. + * + *

Steps carry an absolute time rather than a delay from the previous step, so that the + * contributions of the groups, of the per button delays and, later, of the intermediary frames can + * simply be merged and sorted.

+ * + * @param time the time at which the step happens, in milliseconds since the animation started + * @param slot the slot the step applies to + * @param action what the step does to the slot + * @param item the item to display, only used by {@link StepAction#SET} + * @param sound the sound to play, or null for a silent step + * @param useCache whether the item may be served from its build cache, the {@code use-cache} + * option of the temporary item the step comes from + */ +public record AnimationStep(long time, int slot, @NotNull StepAction action, @Nullable MenuItemStack item, + @Nullable SoundOption sound, boolean useCache) { + + /** + * A step whose item uses the build cache, as items do by default. + */ + public AnimationStep(long time, int slot, @NotNull StepAction action, @Nullable MenuItemStack item, @Nullable SoundOption sound) { + this(time, slot, action, item, sound, true); + } + + /** + * A step revealing the real item of the button owning the slot. + * + * @param time the time of the step + * @param slot the slot to reveal + */ + public AnimationStep(long time, int slot) { + this(time, slot, StepAction.REVEAL, null, null, true); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java new file mode 100644 index 000000000..8783bcb3b --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java @@ -0,0 +1,64 @@ +package fr.maxlego08.menu.animation.compile; + +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * The boundaries of an animation, produced by the first pass of the compiler and used by the second + * one to resolve {@link AnimationAnchor}. + * + * @param groups the timing of every group that had at least one slot, keyed by group number + * @param end the time at which the last group is over, in milliseconds + */ +public record AnimationTiming(@NotNull Map groups, long end) { + + public AnimationTiming { + groups = Map.copyOf(groups); + } + + /** + * An animation with no group at all. + */ + public static final AnimationTiming EMPTY = new AnimationTiming(Map.of(), 0); + + /** + * Resolves an anchor to a time. + * + *

An anchor pointing at a group that holds no slot resolves to the end of the animation, + * which is the least surprising place to put something the author expected to see after a group + * that turned out to be empty.

+ * + * @param anchor the anchor to resolve, null resolves to the start + * @return the time in milliseconds, never negative + */ + public long resolve(@Nullable AnimationAnchor anchor) { + if (anchor == null) return 0; + + return switch (anchor.type()) { + case START -> 0; + case END -> this.end; + case MILLIS -> Math.max(0, anchor.millis()); + case GROUP_START -> { + GroupTiming timing = this.groups.get(anchor.group()); + yield timing == null ? this.end : timing.start(); + } + case GROUP_END -> { + GroupTiming timing = this.groups.get(anchor.group()); + yield timing == null ? this.end : timing.end(); + } + }; + } + + /** + * Returns whether a group holds at least one slot. + * + * @param group the group number + * @return true if the group exists in this animation + */ + public boolean hasGroup(int group) { + return this.groups.containsKey(group); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java new file mode 100644 index 000000000..246f48924 --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java @@ -0,0 +1,42 @@ +package fr.maxlego08.menu.animation.compile; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * The result of compiling the animation of an inventory: everything the engine has to play, and the + * boundaries the steps were laid out against. + * + * @param steps the steps to play, sorted by time then by slot + * @param timing the boundaries of the groups + */ +public record CompiledAnimation(@NotNull List steps, @NotNull AnimationTiming timing) { + + /** + * An animation with nothing to play. + */ + public static final CompiledAnimation EMPTY = new CompiledAnimation(List.of(), AnimationTiming.EMPTY); + + public CompiledAnimation { + steps = List.copyOf(steps); + } + + /** + * Returns whether there is nothing to play. + * + * @return true if the animation has no step + */ + public boolean isEmpty() { + return this.steps.isEmpty(); + } + + /** + * Gets the time at which the last step happens. + * + * @return the duration in milliseconds, 0 if there is no step + */ + public long duration() { + return this.steps.isEmpty() ? 0 : this.steps.getLast().time(); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java new file mode 100644 index 000000000..dc5b3a57e --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java @@ -0,0 +1,10 @@ +package fr.maxlego08.menu.animation.compile; + +/** + * When a group of the animation starts and ends. + * + * @param start the time at which the first slot of the group is revealed, in milliseconds + * @param end the time at which the group is over, in milliseconds + */ +public record GroupTiming(long start, long end) { +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java new file mode 100644 index 000000000..6e68c78bc --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java @@ -0,0 +1,24 @@ +package fr.maxlego08.menu.animation.compile; + +/** + * What an {@link AnimationStep} does to its slot. + */ +public enum StepAction { + + /** + * Displays the real item built by the button owning the slot. This is what the opening + * animation does when it reaches a slot it had hidden. + */ + REVEAL, + + /** + * Displays an item that belongs to the animation itself, such as a placeholder or a temporary + * item. The slot stays owned by the animation. + */ + SET, + + /** + * Empties the slot. The slot stays owned by the animation. + */ + CLEAR +} diff --git a/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java b/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java index 40740bf89..cff4586a7 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java @@ -50,7 +50,8 @@ public enum Permission { ZMENU_BEDROCK, ZMENU_ADDONS, - ZMENU_DOCUMENTATION, ZMENU_WEBSITE; + ZMENU_DOCUMENTATION, ZMENU_WEBSITE, + ZMENU_ANIMATION_RESET; private final String permission; diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/ActionHelper.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/ActionHelper.java index 2e9539244..38d3b3dcd 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/ActionHelper.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/ActionHelper.java @@ -10,13 +10,11 @@ public abstract class ActionHelper extends Action { protected String papi(String string, Player player) { if (string == null || !string.contains("%")) return string; - return Placeholder.Placeholders.getPlaceholder().setPlaceholders(player, string).replace("\uF000", "%"); + return Placeholder.Placeholders.getPlaceholder().setPlaceholders(player, string); } protected List papi(List strings, Player player) { - return Placeholder.Placeholders.getPlaceholder().setPlaceholders(player, strings).stream() - .map(s -> s.replace("\uF000", "%")) - .toList(); + return Placeholder.Placeholders.getPlaceholder().setPlaceholders(player, strings); } } diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/Base64.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/Base64.java deleted file mode 100644 index d5a6a685f..000000000 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/Base64.java +++ /dev/null @@ -1,271 +0,0 @@ -package fr.maxlego08.menu.common.utils; - -import fr.maxlego08.menu.zcore.logger.Logger; - -import java.util.Arrays; - -public final class Base64 { - - static private final int BASELENGTH = 128; - static private final int LOOKUPLENGTH = 64; - static private final int TWENTYFOURBITGROUP = 24; - static private final int EIGHTBIT = 8; - static private final int SIXTEENBIT = 16; - static private final int FOURBYTE = 4; - static private final int SIGN = -128; - static private final char PAD = '='; - static private final boolean fDebug = false; - static final private byte[] base64Alphabet = new byte[BASELENGTH]; - static final private char[] lookUpBase64Alphabet = new char[LOOKUPLENGTH]; - - static { - - Arrays.fill(base64Alphabet, (byte) -1); - for (int i = 'Z'; i >= 'A'; i--) { - base64Alphabet[i] = (byte) (i - 'A'); - } - for (int i = 'z'; i >= 'a'; i--) { - base64Alphabet[i] = (byte) (i - 'a' + 26); - } - - for (int i = '9'; i >= '0'; i--) { - base64Alphabet[i] = (byte) (i - '0' + 52); - } - - base64Alphabet['+'] = 62; - base64Alphabet['/'] = 63; - - for (int i = 0; i <= 25; i++) - lookUpBase64Alphabet[i] = (char) ('A' + i); - - for (int i = 26, j = 0; i <= 51; i++, j++) - lookUpBase64Alphabet[i] = (char) ('a' + j); - - for (int i = 52, j = 0; i <= 61; i++, j++) - lookUpBase64Alphabet[i] = (char) ('0' + j); - lookUpBase64Alphabet[62] = '+'; - lookUpBase64Alphabet[63] = '/'; - - } - - private static boolean isWhiteSpace(char octect) { - return (octect == 0x20 || octect == 0xd || octect == 0xa || octect == 0x9); - } - - private static boolean isPad(char octect) { - return (octect == PAD); - } - - private static boolean isData(char octect) { - return (octect < BASELENGTH && base64Alphabet[octect] != -1); - } - - private static boolean isBase64(char octect) { - return (isWhiteSpace(octect) || isPad(octect) || isData(octect)); - } - - /** - * Encodes hex octects into Base64 - * - * @param binaryData Array containing binaryData - * @return Encoded Base64 array - */ - public static String encode(byte[] binaryData) { - - if (binaryData == null) - return null; - - int lengthDataBits = binaryData.length * EIGHTBIT; - if (lengthDataBits == 0) { - return ""; - } - - int fewerThan24bits = lengthDataBits % TWENTYFOURBITGROUP; - int numberTriplets = lengthDataBits / TWENTYFOURBITGROUP; - int numberQuartet = fewerThan24bits != 0 ? numberTriplets + 1 : numberTriplets; - char[] encodedData = null; - - encodedData = new char[numberQuartet * 4]; - - byte k = 0, l = 0, b1 = 0, b2 = 0, b3 = 0; - - int encodedIndex = 0; - int dataIndex = 0; - if (fDebug) { - Logger.info("number of triplets = " + numberTriplets); - } - - for (int i = 0; i < numberTriplets; i++) { - b1 = binaryData[dataIndex++]; - b2 = binaryData[dataIndex++]; - b3 = binaryData[dataIndex++]; - - if (fDebug) { - Logger.info("b1= " + b1 + ", b2= " + b2 + ", b3= " + b3); - } - - l = (byte) (b2 & 0x0f); - k = (byte) (b1 & 0x03); - - byte val1 = ((b1 & SIGN) == 0) ? (byte) (b1 >> 2) : (byte) ((b1) >> 2 ^ 0xc0); - - byte val2 = ((b2 & SIGN) == 0) ? (byte) (b2 >> 4) : (byte) ((b2) >> 4 ^ 0xf0); - byte val3 = ((b3 & SIGN) == 0) ? (byte) (b3 >> 6) : (byte) ((b3) >> 6 ^ 0xfc); - - if (fDebug) { - Logger.info("val2 = " + val2); - Logger.info("k4 = " + (k << 4)); - Logger.info("vak = " + (val2 | (k << 4))); - } - - encodedData[encodedIndex++] = lookUpBase64Alphabet[val1]; - encodedData[encodedIndex++] = lookUpBase64Alphabet[val2 | (k << 4)]; - encodedData[encodedIndex++] = lookUpBase64Alphabet[(l << 2) | val3]; - encodedData[encodedIndex++] = lookUpBase64Alphabet[b3 & 0x3f]; - } - - // form integral number of 6-bit groups - if (fewerThan24bits == EIGHTBIT) { - b1 = binaryData[dataIndex]; - k = (byte) (b1 & 0x03); - if (fDebug) { - Logger.info("b1=" + b1); - Logger.info("b1<<2 = " + (b1 >> 2)); - } - byte val1 = ((b1 & SIGN) == 0) ? (byte) (b1 >> 2) : (byte) ((b1) >> 2 ^ 0xc0); - encodedData[encodedIndex++] = lookUpBase64Alphabet[val1]; - encodedData[encodedIndex++] = lookUpBase64Alphabet[k << 4]; - encodedData[encodedIndex++] = PAD; - encodedData[encodedIndex++] = PAD; - } else if (fewerThan24bits == SIXTEENBIT) { - b1 = binaryData[dataIndex]; - b2 = binaryData[dataIndex + 1]; - l = (byte) (b2 & 0x0f); - k = (byte) (b1 & 0x03); - - byte val1 = ((b1 & SIGN) == 0) ? (byte) (b1 >> 2) : (byte) ((b1) >> 2 ^ 0xc0); - byte val2 = ((b2 & SIGN) == 0) ? (byte) (b2 >> 4) : (byte) ((b2) >> 4 ^ 0xf0); - - encodedData[encodedIndex++] = lookUpBase64Alphabet[val1]; - encodedData[encodedIndex++] = lookUpBase64Alphabet[val2 | (k << 4)]; - encodedData[encodedIndex++] = lookUpBase64Alphabet[l << 2]; - encodedData[encodedIndex++] = PAD; - } - - return new String(encodedData); - } - - /** - * Decodes Base64 data into octects - * - * @param encoded string containing Base64 data - * @return Array containind decoded data. - */ - public static byte[] decode(String encoded) { - - if (encoded == null) - return null; - - char[] base64Data = encoded.toCharArray(); - // remove white spaces - int len = removeWhiteSpace(base64Data); - - if (len % FOURBYTE != 0) { - return null;//should be divisible by four - } - - int numberQuadruple = (len / FOURBYTE); - - if (numberQuadruple == 0) - return new byte[0]; - - byte[] decodedData = null; - byte b1 = 0, b2 = 0, b3 = 0, b4 = 0; - char d1 = 0, d2 = 0, d3 = 0, d4 = 0; - - int i = 0; - int encodedIndex = 0; - int dataIndex = 0; - decodedData = new byte[(numberQuadruple) * 3]; - - for (; i < numberQuadruple - 1; i++) { - - if (!isData((d1 = base64Data[dataIndex++])) || - !isData((d2 = base64Data[dataIndex++])) || - !isData((d3 = base64Data[dataIndex++])) || - !isData((d4 = base64Data[dataIndex++]))) - return null;//if found "no data" just return null - - b1 = base64Alphabet[d1]; - b2 = base64Alphabet[d2]; - b3 = base64Alphabet[d3]; - b4 = base64Alphabet[d4]; - - decodedData[encodedIndex++] = (byte) (b1 << 2 | b2 >> 4); - decodedData[encodedIndex++] = (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); - decodedData[encodedIndex++] = (byte) (b3 << 6 | b4); - } - - if (!isData((d1 = base64Data[dataIndex++])) || - !isData((d2 = base64Data[dataIndex++]))) { - return null;//if found "no data" just return null - } - - b1 = base64Alphabet[d1]; - b2 = base64Alphabet[d2]; - - d3 = base64Data[dataIndex++]; - d4 = base64Data[dataIndex++]; - if (!isData((d3)) || - !isData((d4))) {//Check if they are PAD characters - if (isPad(d3) && isPad(d4)) { //Two PAD e.g. 3c[Pad][Pad] - if ((b2 & 0xf) != 0)//last 4 bits should be zero - return null; - byte[] tmp = new byte[i * 3 + 1]; - System.arraycopy(decodedData, 0, tmp, 0, i * 3); - tmp[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); - return tmp; - } else if (!isPad(d3) && isPad(d4)) { //One PAD e.g. 3cQ[Pad] - b3 = base64Alphabet[d3]; - if ((b3 & 0x3) != 0)//last 2 bits should be zero - return null; - byte[] tmp = new byte[i * 3 + 2]; - System.arraycopy(decodedData, 0, tmp, 0, i * 3); - tmp[encodedIndex++] = (byte) (b1 << 2 | b2 >> 4); - tmp[encodedIndex] = (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); - return tmp; - } else { - return null;//an error like "3c[Pad]r", "3cdX", "3cXd", "3cXX" where X is non data - } - } else { //No PAD e.g 3cQl - b3 = base64Alphabet[d3]; - b4 = base64Alphabet[d4]; - decodedData[encodedIndex++] = (byte) (b1 << 2 | b2 >> 4); - decodedData[encodedIndex++] = (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); - decodedData[encodedIndex++] = (byte) (b3 << 6 | b4); - - } - - return decodedData; - } - - /** - * remove WhiteSpace from MIME containing encoded Base64 data. - * - * @param data the byte array of base64 data (with WS) - * @return the new length - */ - private static int removeWhiteSpace(char[] data) { - if (data == null) - return 0; - - // count characters that's not whitespace - int newSize = 0; - int len = data.length; - for (int i = 0; i < len; i++) { - if (!isWhiteSpace(data[i])) - data[newSize++] = data[i]; - } - return newSize; - } -} \ No newline at end of file diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/ClickTypeParser.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/ClickTypeParser.java new file mode 100644 index 000000000..7d8a354b6 --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/ClickTypeParser.java @@ -0,0 +1,45 @@ +package fr.maxlego08.menu.common.utils; + +import fr.maxlego08.menu.api.configuration.Configuration; +import fr.maxlego08.menu.zcore.logger.Logger; +import fr.maxlego08.menu.zcore.logger.Logger.LogType; +import org.bukkit.event.inventory.ClickType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class ClickTypeParser { + + private ClickTypeParser() { + } + + /** + * Transforms a list of names into click types. {@code ALL} or {@code ANY} adds the click types of + * the configuration, as does an empty list. + * + * @param names the click types as written in a file + * @return the click types + */ + public static List parse(List names) { + List clickTypes = new ArrayList<>(); + names.forEach(clickType -> { + if (clickType == null) return; + if (clickType.equalsIgnoreCase("all") || clickType.equalsIgnoreCase("any")) { + clickTypes.addAll(Configuration.allClicksType); + } else { + try { + clickTypes.add(ClickType.valueOf(clickType.toUpperCase(Locale.ROOT))); + } catch (Exception ignored) { + Logger.info(clickType + " click type was not found.", LogType.ERROR); + } + } + }); + + if (clickTypes.isEmpty()) { + clickTypes.addAll(Configuration.allClicksType); + } + + return clickTypes; + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/LocationUtils.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/LocationUtils.java index 219c6f430..872bdec1a 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/LocationUtils.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/LocationUtils.java @@ -1,16 +1,11 @@ package fr.maxlego08.menu.common.utils; import org.bukkit.Bukkit; -import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.World; public abstract class LocationUtils extends PapiUtils { - protected Location changeStringLocationToLocation(String string) { - return this.changeStringLocationToLocationEye(string); - } - protected Location changeStringLocationToLocationEye(String string) { String[] locationArray = string.split(","); World w = Bukkit.getServer().getWorld(locationArray[0]); @@ -25,22 +20,4 @@ protected Location changeStringLocationToLocationEye(String string) { return new Location(w, x, y, z); } - protected String changeLocationToString(Location location) { - return location.getWorld().getName() + "," + location.getBlockX() + "," + location.getBlockY() + "," + location.getBlockZ(); - } - - protected String changeLocationToStringEye(Location location) { - return location.getWorld().getName() + "," + location.getBlockX() + "," + location.getBlockY() + "," + location.getBlockZ() + "," + location.getYaw() + "," + location.getPitch(); - } - - protected Chunk changeStringChuncToChunk(String chunk) { - String[] a = chunk.split(","); - World w = Bukkit.getServer().getWorld(a[0]); - return w.getChunkAt(Integer.parseInt(a[1]), Integer.parseInt(a[2])); - } - - protected String changeChunkToString(Chunk chunk) { - return chunk.getWorld().getName() + "," + chunk.getX() + "," + chunk.getZ(); - } - } diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/MessageUtils.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/MessageUtils.java index 501108108..fcb1e1de3 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/MessageUtils.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/MessageUtils.java @@ -3,15 +3,10 @@ import fr.maxlego08.menu.api.MenuPlugin; import fr.maxlego08.menu.api.utils.IMessage; import fr.maxlego08.menu.api.utils.Message; -import fr.maxlego08.menu.api.utils.version.MinecraftVersion; -import fr.maxlego08.menu.zcore.logger.Logger; -import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import java.lang.reflect.Constructor; - /** * Allows you to manage messages sent to players and the console * @@ -153,46 +148,4 @@ protected static String getCenteredMessage(String message) { return sb + message; } - protected final Class getNMSClass(String name) { - try { - return Class.forName("net.minecraft.server." + Bukkit.getServer().getClass().getPackage().getName().split("\\.")[3] + "." + name); - } catch (ClassNotFoundException e) { - Logger.error(e); - } - return null; - } - - protected void title(Player player, String title, String subtitle, int fadeInTime, int showTime, int fadeOutTime) { - - if (MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.13"))) { - player.sendTitle(title, subtitle, fadeInTime, showTime, fadeOutTime); - return; - } - - try { - Object chatTitle = this.getNMSClass("IChatBaseComponent").getDeclaredClasses()[0].getMethod("a", String.class).invoke(null, "{\"text\": \"" + title + "\"}"); - Constructor titleConstructor = this.getNMSClass("PacketPlayOutTitle").getConstructor(this.getNMSClass("PacketPlayOutTitle").getDeclaredClasses()[0], this.getNMSClass("IChatBaseComponent"), int.class, int.class, int.class); - Object packet = titleConstructor.newInstance(this.getNMSClass("PacketPlayOutTitle").getDeclaredClasses()[0].getField("TITLE").get(null), chatTitle, fadeInTime, showTime, fadeOutTime); - - Object chatsTitle = this.getNMSClass("IChatBaseComponent").getDeclaredClasses()[0].getMethod("a", String.class).invoke(null, "{\"text\": \"" + subtitle + "\"}"); - Constructor timingTitleConstructor = this.getNMSClass("PacketPlayOutTitle").getConstructor(this.getNMSClass("PacketPlayOutTitle").getDeclaredClasses()[0], this.getNMSClass("IChatBaseComponent"), int.class, int.class, int.class); - Object timingPacket = timingTitleConstructor.newInstance(this.getNMSClass("PacketPlayOutTitle").getDeclaredClasses()[0].getField("SUBTITLE").get(null), chatsTitle, fadeInTime, showTime, fadeOutTime); - - this.sendPacket(player, packet); - this.sendPacket(player, timingPacket); - } catch (Exception e) { - Logger.error(e); - } - } - - protected final void sendPacket(Player player, Object packet) { - try { - Object handle = player.getClass().getMethod("getHandle").invoke(player); - Object playerConnection = handle.getClass().getField("playerConnection").get(handle); - playerConnection.getClass().getMethod("sendPacket", this.getNMSClass("Packet")).invoke(playerConnection, packet); - } catch (Exception e) { - Logger.error(e); - } - } - } diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/PapiUtils.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/PapiUtils.java index d2b4de264..97db58ab2 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/PapiUtils.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/PapiUtils.java @@ -1,17 +1,22 @@ package fr.maxlego08.menu.common.utils; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import fr.maxlego08.menu.api.configuration.Configuration; import fr.maxlego08.menu.api.placeholder.Placeholder; import org.bukkit.OfflinePlayer; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; public class PapiUtils extends TranslationHelper { + private static final long MAXIMUM_CACHE_SIZE = 10_000; + private static volatile Placeholder placeholder; - private transient final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private static volatile Cache cache; + private static volatile long cacheDuration = -1; private Placeholder use() { if (placeholder == null) { @@ -20,6 +25,25 @@ private Placeholder use() { return placeholder; } + private static Cache getCache() { + long duration = Configuration.cachePlaceholderAPI; + Cache current = cache; + if (current != null && cacheDuration == duration) return current; + + synchronized (PapiUtils.class) { + current = cache; + if (current == null || cacheDuration != duration) { + current = CacheBuilder.newBuilder() + .maximumSize(MAXIMUM_CACHE_SIZE) + .expireAfterWrite(Math.max(duration, 0), TimeUnit.MILLISECONDS) + .build(); + cacheDuration = duration; + cache = current; + } + return current; + } + } + public Object papi(Object placeHolder, OfflinePlayer player, boolean useCache) { return placeHolder instanceof String ? this.papi((String) placeHolder, player, useCache) : placeHolder; } @@ -28,22 +52,30 @@ public String papi(String placeHolder, OfflinePlayer player, boolean useCache) { if (placeHolder == null) return null; if (player == null) return placeHolder; if (!placeHolder.contains("%")) return placeHolder; - String cacheKey = placeHolder + ";" + player.getUniqueId(); - CacheEntry cachedResult = this.cache.get(cacheKey); - if (Configuration.enableCachePlaceholderAPI && cachedResult != null && cachedResult.isValid() && useCache) { - return cachedResult.value; + if (!Configuration.enableCachePlaceholderAPI) { + return this.parse(placeHolder, player); } - String result = this.use().setPlaceholders(player, placeHolder).replace("%player%", player.getName()); + Cache cache = getCache(); + String cacheKey = placeHolder + ";" + player.getUniqueId(); - if (Configuration.enableCachePlaceholderAPI) { - this.cache.put(cacheKey, new CacheEntry(result, System.currentTimeMillis())); + if (useCache) { + String cachedResult = cache.getIfPresent(cacheKey); + if (cachedResult != null) return cachedResult; } + String result = this.parse(placeHolder, player); + cache.put(cacheKey, result); return result; } + private String parse(String placeHolder, OfflinePlayer player) { + String result = this.use().setPlaceholders(player, placeHolder); + String playerName = player.getName(); + return playerName == null ? result : result.replace("%player%", playerName); + } + public List papi(List placeHolders, OfflinePlayer player, boolean useCache) { if (player == null) return placeHolders; List parsed = new ArrayList<>(placeHolders.size()); @@ -52,15 +84,4 @@ public List papi(List placeHolders, OfflinePlayer player, boolea } return parsed; } - - /** - * @param timeStamp Time when the cache entry was created - */ - private record CacheEntry(String value, long timeStamp) { - - public boolean isValid() { - // Check if the cache entry is still valid (not older than 1000 milliseconds) - return System.currentTimeMillis() - this.timeStamp getPluginIgnoreCase(String pluginName) { + if (pluginName == null) { + return Optional.empty(); + } + for (Plugin plugin : Bukkit.getPluginManager().getPlugins()) { + if (plugin.getName().equalsIgnoreCase(pluginName)) { + return Optional.of(plugin); + } + } + return Optional.empty(); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/ZUtils.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/ZUtils.java index 39204020e..30f2db4da 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/ZUtils.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/ZUtils.java @@ -3,9 +3,7 @@ import fr.maxlego08.menu.api.MenuPlugin; import fr.maxlego08.menu.api.utils.EnumInventory; import fr.maxlego08.menu.api.utils.Message; -import fr.maxlego08.menu.api.utils.version.MinecraftVersion; import fr.maxlego08.menu.common.enums.Permission; -import fr.maxlego08.menu.common.utils.nms.NMSUtils; import fr.maxlego08.menu.zcore.logger.Logger; import net.md_5.bungee.api.chat.BaseComponent; import net.md_5.bungee.api.chat.ClickEvent; @@ -36,37 +34,20 @@ import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.*; -import java.util.function.BiConsumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @SuppressWarnings("deprecation") public abstract class ZUtils extends MessageUtils { - private static final Timer TIMER = new Timer(); - // For plugin support from 1.8 to 1.12 - private static Material[] byId; - - static { - if (MinecraftVersion.getCurrentVersion().isBefore(MinecraftVersion.parse("1.13"))) { - byId = new Material[0]; - for (Material material : Material.values()) { - if (byId.length <= material.getId()) { - byId = Arrays.copyOfRange(byId, 0, material.getId() + 2); - } - byId[material.getId()] = material; - } - } - } + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("#[a-fA-F0-9]{6}"); + private static final Pattern HEX_COLOR_REVERSE_PATTERN = Pattern.compile(net.md_5.bungee.api.ChatColor.COLOR_CHAR + "x[a-fA-F0-9-" + net.md_5.bungee.api.ChatColor.COLOR_CHAR + "]{12}"); protected String findPlayerLocale(Player player) { - if (MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.13"))) { - try { - return player != null ? player.getLocale() : null; - } catch (Exception exception) { - return null; - } + try { + return player != null ? player.getLocale() : null; + } catch (Exception exception) { + return null; } - return null; } protected int parseInt(String value, int defaultValue) { @@ -153,10 +134,6 @@ public static void give(Player player, ItemStack item) { else player.getInventory().addItem(item); } - protected Material getMaterial(int id) { - return byId.length > id && id >= 0 ? byId[id] : Material.AIR; - } - /** * Allows to check if an itemstack has a display name * @@ -288,69 +265,28 @@ protected boolean hasPermission(Permissible permissible, String permission) { } - /** - * Schedules a fixed-rate execution of a task associated with the given plugin. - * - * @param plugin the MenuPlugin for which the task is scheduled - * @param delay the delay in milliseconds before task is to be executed and between successive task executions - * @param consumer the BiConsumer to be executed with the TimerTask and a boolean indicating the task's success - * @return the scheduled TimerTask - */ - protected TimerTask scheduleFix(MenuPlugin plugin, long delay, BiConsumer consumer) { - return this.scheduleFix(plugin, delay, delay, consumer); - } - - /** - * Schedules a fixed-rate execution of a task associated with the given plugin. - * - * @param plugin the MenuPlugin for which the task is scheduled - * @param startAt the delay in milliseconds before the task is first executed - * @param delay the delay in milliseconds between successive task executions - * @param consumer the BiConsumer to be executed with the TimerTask and a boolean indicating the task's success - * @return the scheduled TimerTask - */ - protected TimerTask scheduleFix(MenuPlugin plugin, long startAt, long delay, BiConsumer consumer) { - TimerTask task = new TimerTask() { - @Override - public void run() { - if (!plugin.isEnabled()) { - this.cancel(); - consumer.accept(this, false); - return; - } - plugin.getScheduler().runNextTick(w -> consumer.accept(this, true)); - } - }; - TIMER.scheduleAtFixedRate(task, startAt, delay); - return task; - } - protected String color(String message) { if (message == null) return null; - if (NMSUtils.isHexColor()) { - Pattern pattern = Pattern.compile("#[a-fA-F0-9]{6}"); - Matcher matcher = pattern.matcher(message); + if (message.indexOf('#') >= 0) { + Matcher matcher = HEX_COLOR_PATTERN.matcher(message); + StringBuilder builder = new StringBuilder(message.length() + 32); while (matcher.find()) { - String color = message.substring(matcher.start(), matcher.end()); - message = message.replace(color, String.valueOf(net.md_5.bungee.api.ChatColor.of(color))); - matcher = pattern.matcher(message); + matcher.appendReplacement(builder, Matcher.quoteReplacement(net.md_5.bungee.api.ChatColor.of(matcher.group()).toString())); } + message = matcher.appendTail(builder).toString(); } return net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', message); } public String colorReverse(String message) { - Pattern pattern = Pattern.compile(net.md_5.bungee.api.ChatColor.COLOR_CHAR + "x[a-fA-F0-9-" + net.md_5.bungee.api.ChatColor.COLOR_CHAR + "]{12}"); - Matcher matcher = pattern.matcher(message); + Matcher matcher = HEX_COLOR_REVERSE_PATTERN.matcher(message); + StringBuilder builder = new StringBuilder(message.length()); while (matcher.find()) { - String color = message.substring(matcher.start(), matcher.end()); - String colorReplace = color.replace("§x", "#"); - colorReplace = colorReplace.replace("§", ""); - message = message.replace(color, colorReplace); - matcher = pattern.matcher(message); + String colorReplace = matcher.group().replace("§x", "#").replace("§", ""); + matcher.appendReplacement(builder, Matcher.quoteReplacement(colorReplace)); } - return message.replace("§", "&"); + return matcher.appendTail(builder).toString().replace("§", "&"); } protected List color(List messages) { @@ -454,7 +390,7 @@ private void applyTextureUrl(ItemStack itemStack, String url) { protected Object getPrivateField(Object object, String field) throws SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException { Class clazz = object.getClass(); - Field objectField = field.equals("commandMap") ? clazz.getDeclaredField(field) : field.equals("knownCommands") ? MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.13")) ? clazz.getSuperclass().getDeclaredField(field) : clazz.getDeclaredField(field) : null; + Field objectField = field.equals("commandMap") ? clazz.getDeclaredField(field) : field.equals("knownCommands") ? clazz.getSuperclass().getDeclaredField(field) : null; objectField.setAccessible(true); Object result = objectField.get(object); objectField.setAccessible(false); diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java index aa6cefe36..dbb0d85bf 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java @@ -2,9 +2,11 @@ import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; +import java.io.IOException; import java.nio.file.Path; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -36,6 +38,34 @@ public static Optional getYamlFileEntry(Path path) { return Optional.of(entry); } + /** + * Like {@link #getYamlFileEntry(Path)}, but a file that is not valid YAML throws instead of + * being read as an empty configuration. A file that fails is not cached. + * + * @throws IOException if the file cannot be read + * @throws InvalidConfigurationException if the file is not valid YAML + */ + public static Optional getYamlFileEntryStrict(Path path) throws IOException, InvalidConfigurationException { + File file = path.toFile(); + + if (!file.exists()) { + cache.invalidate(path); + return Optional.empty(); + } + + YamlFileCacheEntry entry = cache.getIfPresent(path); + + if (entry != null && entry.isUpToDate()) { + return Optional.of(entry); + } + + YamlConfiguration config = new YamlConfiguration(); + config.load(file); + entry = new YamlFileCacheEntry(file, config); + cache.put(path, entry); + return Optional.of(entry); + } + public static Optional getYamlConfiguration(Path path) { return getYamlFileEntry(path).map(YamlFileCacheEntry::getYamlConfiguration); } diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/Base64ItemStack.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/Base64ItemStack.java index aa7ba4483..af56d2c07 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/Base64ItemStack.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/Base64ItemStack.java @@ -1,14 +1,12 @@ package fr.maxlego08.menu.common.utils.nms; import fr.maxlego08.menu.zcore.logger.Logger; - - -import fr.maxlego08.menu.common.utils.Base64; import org.bukkit.inventory.ItemStack; import org.bukkit.util.io.BukkitObjectInputStream; import org.bukkit.util.io.BukkitObjectOutputStream; import java.io.*; +import java.util.Base64; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -25,7 +23,7 @@ public static String encode(ItemStack item) { ObjectOutputStream objectOutputStream = new BukkitObjectOutputStream(gzipOutputStream); objectOutputStream.writeObject(item); objectOutputStream.close(); - return Base64.encode(byteArrayOutputStream.toByteArray()); + return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()); } catch (IOException exception) { Logger.error(exception); return null; @@ -34,7 +32,7 @@ public static String encode(ItemStack item) { public static ItemStack decode(String data) { try { - ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.decode(data)); + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.getMimeDecoder().decode(data)); GZIPInputStream gzipInputStream = new GZIPInputStream(byteArrayInputStream); ObjectInputStream objectInputStream = new BukkitObjectInputStream(gzipInputStream); ItemStack item = (ItemStack) objectInputStream.readObject(); diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/EnumReflectionCompound.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/EnumReflectionCompound.java deleted file mode 100644 index e6c2dad50..000000000 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/EnumReflectionCompound.java +++ /dev/null @@ -1,114 +0,0 @@ -package fr.maxlego08.menu.common.utils.nms; - -public enum EnumReflectionCompound { - - V1_8_8("getTag", "setTag", "hasKey", "getBoolean", "getFloat", "getDouble", "getLong", "getInt", "getString", "setBoolean", "setFloat", "setDouble", "setLong", "setInt", "setString"), - - V1_18_2("t", "c", "e", "q", "j", "k", "i", "h", "l", "a", "a", "a", "a", "a", "a"), - - V1_17("s", "c", "e", "q", "j", "k", "i", "h", "l", "a", "a", "a", "a", "a", "a"), - - V1_12("v", "c", "e", "q", "j", "k", "i", "h", "l", "a", "a", "a", "a", "a", "a"), - - V1_19("u", "c", "e", "q", "j", "k", "i", "h", "l", "a", "a", "a", "a", "a", "a"), - - ; - - private final String methodGetTag; - private final String methodSetTag; - - private final String methodHasKey; - - private final String methodGetBoolean; - private final String methodGetFloat; - private final String methodGetDouble; - private final String methodGetLong; - private final String methodGetInt; - private final String methodGetString; - - private final String methodSetBoolean; - private final String methodSetFloat; - private final String methodSetDouble; - private final String methodSetLong; - private final String methodSetInt; - private final String methodSetString; - - EnumReflectionCompound(String methodGetTag, String methodSetTag, String methodHaskey, String methodGetBoolean, String methodGetFloat, String methodGetDouble, String methodGetLong, String methodGetInt, String methodGetString, String methodSetBoolean, String methodSetFloat, String methodSetDouble, String methodSetLong, String methodSetInt, String methodSetString) { - this.methodGetTag = methodGetTag; - this.methodSetTag = methodSetTag; - this.methodHasKey = methodHaskey; - this.methodGetBoolean = methodGetBoolean; - this.methodGetFloat = methodGetFloat; - this.methodGetDouble = methodGetDouble; - this.methodGetLong = methodGetLong; - this.methodGetInt = methodGetInt; - this.methodGetString = methodGetString; - this.methodSetBoolean = methodSetBoolean; - this.methodSetFloat = methodSetFloat; - this.methodSetDouble = methodSetDouble; - this.methodSetLong = methodSetLong; - this.methodSetInt = methodSetInt; - this.methodSetString = methodSetString; - } - - public String getMethodGetTag() { - return this.methodGetTag; - } - - public String getMethodSetTag() { - return this.methodSetTag; - } - - public String getMethodHasKey() { - return this.methodHasKey; - } - - public String getMethodGetBoolean() { - return this.methodGetBoolean; - } - - public String getMethodGetFloat() { - return this.methodGetFloat; - } - - public String getMethodGetDouble() { - return this.methodGetDouble; - } - - public String getMethodGetLong() { - return this.methodGetLong; - } - - public String getMethodGetInt() { - return this.methodGetInt; - } - - public String getMethodGetString() { - return this.methodGetString; - } - - public String getMethodSetBoolean() { - return this.methodSetBoolean; - } - - public String getMethodSetFloat() { - return this.methodSetFloat; - } - - public String getMethodSetDouble() { - return this.methodSetDouble; - } - - public String getMethodSetLong() { - return this.methodSetLong; - } - - public String getMethodSetInt() { - return this.methodSetInt; - } - - public String getMethodSetString() { - return this.methodSetString; - } - - } \ No newline at end of file diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackCompound.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackCompound.java deleted file mode 100644 index 290ab06ba..000000000 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackCompound.java +++ /dev/null @@ -1,351 +0,0 @@ -package fr.maxlego08.menu.common.utils.nms; - -import fr.maxlego08.menu.zcore.logger.Logger; - -import fr.maxlego08.menu.nms.NMSHandler; -import fr.maxlego08.menu.api.utils.version.MinecraftVersion; -import fr.maxlego08.menu.common.utils.nms.ItemStackUtils.EnumReflectionItemStack; -import org.bukkit.inventory.ItemStack; - -public class ItemStackCompound { - - /** - * Singleton instance of ItemStackCompound based on the current NmsVersion. - */ - public static ItemStackCompound itemStackCompound; - - // Static block to initialize the itemStackCompound based on the NmsVersion - static { - MinecraftVersion currentVersion = MinecraftVersion.getCurrentVersion(); - if (currentVersion.equals(MinecraftVersion.parse("1.18.2"))) { - itemStackCompound = new ItemStackCompound(EnumReflectionCompound.V1_18_2); - } else if (currentVersion.isAtLeast(MinecraftVersion.parse("1.12"))) { - itemStackCompound = new ItemStackCompound(EnumReflectionCompound.V1_12); - } else if (currentVersion.isAtLeast(MinecraftVersion.parse("1.19"))) { - itemStackCompound = new ItemStackCompound(EnumReflectionCompound.V1_19); - } else if (currentVersion.isAtLeast(MinecraftVersion.parse("1.17"))) { - itemStackCompound = new ItemStackCompound(EnumReflectionCompound.V1_17); - } else itemStackCompound = new ItemStackCompound(EnumReflectionCompound.V1_8_8); - } - - private final EnumReflectionCompound reflection; - - /** - * Constructs an ItemStackCompound instance based on the given EnumReflectionCompound. - * - * @param reflection The EnumReflectionCompound representing the NBT tag reflection value. - */ - public ItemStackCompound(EnumReflectionCompound reflection) { - super(); - this.reflection = reflection; - } - - /** - * Retrieves the NBT tag compound from the given ItemStack. - * - * @param itemStack The ItemStack to retrieve the NBT tag from. - * @return The NBT tag compound object. - * @throws Exception If an error occurs during the process. - */ - public Object getCompound(ItemStack itemStack) throws Exception { - if (itemStack == null) return null; - Object localItemStackObject = EnumReflectionItemStack.CRAFTITEMSTACK.getClassz().getMethod("asNMSCopy", ItemStack.class).invoke(null, itemStack); - Object localCompoundObject = localItemStackObject.getClass().getMethod(this.reflection.getMethodGetTag()).invoke(localItemStackObject); - if (localCompoundObject != null) { - return localCompoundObject; - } - return EnumReflectionItemStack.NBTTAGCOMPOUND.getClassz().newInstance(); - } - - - /** - * Applies the given NBT tag compound to the ItemStack. - * - * @param itemStack The ItemStack to apply the NBT tag to. - * @param compoundObject The NBT tag compound to apply. - * @return The modified ItemStack. - * @throws Exception If an error occurs during the process. - */ - public ItemStack applyCompound(ItemStack itemStack, Object compoundObject) throws Exception { - Object localItemStackObject = EnumReflectionItemStack.CRAFTITEMSTACK.getClassz().getMethod("asNMSCopy", ItemStack.class).invoke(null, itemStack); - localItemStackObject.getClass().getMethod(this.reflection.getMethodSetTag(), EnumReflectionItemStack.NBTTAGCOMPOUND.getClassz()).invoke(localItemStackObject, compoundObject); - return (ItemStack) EnumReflectionItemStack.CRAFTITEMSTACK.getClassz().getMethod("asBukkitCopy", EnumReflectionItemStack.ITEMSTACK.getClassz()).invoke(null, new Object[]{localItemStackObject}); - } - - /** - * Sets a string value in the NBT tag compound. - * - * @param itemStack The ItemStack to set the value in. - * @param key The key of the value. - * @param value The string value to set. - * @return The modified ItemStack. - */ - public ItemStack setString(ItemStack itemStack, String key, String value) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.setString(itemStack, key, value); - try { - Object compoundObject = this.getCompound(itemStack); - if (compoundObject == null) return null; - compoundObject.getClass().getMethod(this.reflection.getMethodSetString(), String.class, String.class).invoke(compoundObject, key, value); - return this.applyCompound(itemStack, compoundObject); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - /** - * Retrieves a string value from the NBT tag compound. - * - * @param itemStack The ItemStack to retrieve the value from. - * @param key The key of the value. - * @return The string value. - */ - public String getString(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.getString(itemStack, key); - try { - Object compoundObject = this.getCompound(itemStack); - if (compoundObject == null) return null; - return (String) compoundObject.getClass().getMethod(this.reflection.getMethodGetString(), String.class).invoke(compoundObject, new Object[]{key}); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - - /** - * Retrieves a double value from the NBT tag compound. - * - * @param itemStack The ItemStack to retrieve the value from. - * @param key The key of the value. - * @return The double value. - */ - public double getDouble(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.getDouble(itemStack, key); - try { - Object compoundObject = this.getCompound(itemStack); - if (compoundObject == null) return 0; - return (double) compoundObject.getClass().getMethod(this.reflection.getMethodGetDouble(), String.class).invoke(compoundObject, new Object[]{key}); - } catch (Exception exception) { - Logger.error(exception); - } - return 0; - - } - - /** - * Retrieves a long value from the NBT tag compound. - * - * @param itemStack The ItemStack to retrieve the value from. - * @param key The key of the value. - * @return The long value. - */ - public long getLong(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.getLong(itemStack, key); - try { - Object compoundObject = this.getCompound(itemStack); - if (compoundObject == null) return 0; - return (long) compoundObject.getClass().getMethod(this.reflection.getMethodGetLong(), String.class).invoke(compoundObject, new Object[]{key}); - } catch (Exception exception) { - Logger.error(exception); - } - return 0; - } - - /** - * Retrieves an integer value from the NBT tag compound. - * - * @param itemStack The ItemStack to retrieve the value from. - * @param key The key of the value. - * @return The integer value. - */ - public int getInt(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.getInt(itemStack, key); - try { - Object compoundObject = this.getCompound(itemStack); - if (compoundObject == null) return 0; - return (int) compoundObject.getClass().getMethod(this.reflection.getMethodGetInt(), String.class).invoke(compoundObject, new Object[]{key}); - } catch (Exception exception) { - Logger.error(exception); - } - return 0; - - } - - /** - * Retrieves a float value from the NBT tag compound. - * - * @param itemStack The ItemStack to retrieve the value from. - * @param key The key of the value. - * @return The float value. - */ - public float getFloat(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.getFloat(itemStack, key); - try { - Object compoundObject = this.getCompound(itemStack); - return (float) compoundObject.getClass().getMethod(this.reflection.getMethodGetFloat(), String.class).invoke(compoundObject, new Object[]{key}); - } catch (Exception exception) { - Logger.error(exception); - } - return 0; - - } - - /** - * Retrieves a boolean value from the NBT tag compound. - * - * @param itemStack The ItemStack to retrieve the value from. - * @param key The key of the value. - * @return The boolean value. - */ - public boolean getBoolean(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.getBoolean(itemStack, key); - try { - Object compoundObject = this.getCompound(itemStack); - return (boolean) compoundObject.getClass().getMethod(this.reflection.getMethodGetBoolean(), String.class).invoke(compoundObject, new Object[]{key}); - } catch (Exception exception) { - Logger.error(exception); - } - return false; - - } - - /** - * Sets an integer value in the NBT tag compound. - * - * @param itemStack The ItemStack to set the value in. - * @param key The key of the value. - * @param value The integer value to set. - * @return The modified ItemStack. - */ - public ItemStack setInt(ItemStack itemStack, String key, int value) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.setInt(itemStack, key, value); - try { - Object compoundObject = this.getCompound(itemStack); - compoundObject.getClass().getMethod(this.reflection.getMethodSetInt(), String.class, int.class).invoke(compoundObject, key, value); - return this.applyCompound(itemStack, compoundObject); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - /** - * Sets a long value in the NBT tag compound. - * - * @param itemStack The ItemStack to set the value in. - * @param key The key of the value. - * @param value The long value to set. - * @return The modified ItemStack. - */ - public ItemStack setLong(ItemStack itemStack, String key, long value) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.setLong(itemStack, key, value); - try { - Object compoundObject = this.getCompound(itemStack); - compoundObject.getClass().getMethod(this.reflection.getMethodSetLong(), String.class, long.class).invoke(compoundObject, key, value); - return this.applyCompound(itemStack, compoundObject); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - /** - * Sets a float value in the NBT tag compound. - * - * @param itemStack The ItemStack to set the value in. - * @param key The key of the value. - * @param value The float value to set. - * @return The modified ItemStack. - */ - public ItemStack setFloat(ItemStack itemStack, String key, float value) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.setFloat(itemStack, key, value); - try { - Object compoundObject = this.getCompound(itemStack); - compoundObject.getClass().getMethod(this.reflection.getMethodSetFloat(), String.class, float.class).invoke(compoundObject, key, value); - return this.applyCompound(itemStack, compoundObject); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - /** - * Sets a boolean value in the NBT tag compound. - * - * @param itemStack The ItemStack to set the value in. - * @param key The key of the value. - * @param value The boolean value to set. - * @return The modified ItemStack. - */ - public ItemStack setBoolean(ItemStack itemStack, String key, boolean value) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.setBoolean(itemStack, key, value); - - try { - Object compoundObject = this.getCompound(itemStack); - compoundObject.getClass().getMethod(this.reflection.getMethodSetBoolean(), String.class, boolean.class).invoke(compoundObject, key, value); - return this.applyCompound(itemStack, compoundObject); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - /** - * Sets a double value in the NBT tag compound. - * - * @param itemStack The ItemStack to set the value in. - * @param key The key of the value. - * @param value The double value to set. - * @return The modified ItemStack. - */ - public ItemStack setDouble(ItemStack itemStack, String key, double value) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.setDouble(itemStack, key, value); - try { - Object compoundObject = this.getCompound(itemStack); - compoundObject.getClass().getMethod(this.reflection.getMethodSetDouble(), String.class, double.class).invoke(compoundObject, key, value); - return this.applyCompound(itemStack, compoundObject); - } catch (Exception exception) { - Logger.error(exception); - } - return null; - - } - - /** - * Checks if a specified key is present in the NBT tag compound. - * - * @param itemStack The ItemStack to check. - * @param key The key to check for. - * @return True if the key is present, false otherwise. - */ - public boolean isKey(ItemStack itemStack, String key) { - NMSHandler handler = NMSProvider.getHandler(); - if (handler != null) return handler.hasKey(itemStack, key); - try { - Object nbttagCompound = this.getCompound(itemStack); - if (nbttagCompound == null) return false; - return (boolean) nbttagCompound.getClass().getMethod(this.reflection.getMethodHasKey(), String.class).invoke(nbttagCompound, new Object[]{key}); - } catch (Exception ignored) { - } - return false; - } -} diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackUtils.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackUtils.java index 5be32e82e..0bfc699ff 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackUtils.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/ItemStackUtils.java @@ -1,68 +1,16 @@ package fr.maxlego08.menu.common.utils.nms; -import fr.maxlego08.menu.zcore.logger.Logger; - -import fr.maxlego08.menu.api.configuration.Configuration; -import fr.maxlego08.menu.api.utils.version.MinecraftVersion; -import fr.maxlego08.menu.common.utils.Base64; -import org.bukkit.Bukkit; import org.bukkit.inventory.ItemStack; -import java.io.*; -import java.lang.reflect.Constructor; -import java.util.HashMap; -import java.util.Map; -import java.util.zip.GZIPInputStream; - public class ItemStackUtils { - private static final NmsVersion NMS_VERSION = NmsVersion.nmsVersion; - private static final Map itemStackSerialized = new HashMap<>(); - private static final MinecraftVersion MINECRAFT_VERSION = MinecraftVersion.getCurrentVersion(); - public static String serializeItemStack(ItemStack paramItemStack) { if (paramItemStack == null) { return "null"; } - if (itemStackSerialized.containsKey(paramItemStack)) { - return itemStackSerialized.get(paramItemStack); - } - - if (MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.20.5"))) { - return Base64ItemStack.encode(paramItemStack); - } - - ByteArrayOutputStream localByteArrayOutputStream = null; - try { - Class localClass = EnumReflectionItemStack.NBTTAGCOMPOUND.getClassz(); - Constructor localConstructor = localClass.getConstructor(); - Object localObject1 = localConstructor.newInstance(); - Object localObject2 = EnumReflectionItemStack.CRAFTITEMSTACK.getClassz() - .getMethod("asNMSCopy", ItemStack.class) - .invoke(null, paramItemStack); - - if (NMSUtils.isNewNBTVersion()) { - EnumReflectionItemStack.ITEMSTACK.getClassz().getMethod("b", localClass) - .invoke(localObject2, localObject1); - } else { - EnumReflectionItemStack.ITEMSTACK.getClassz().getMethod("save", localClass) - .invoke(localObject2, localObject1); - } - - localByteArrayOutputStream = new ByteArrayOutputStream(); - EnumReflectionItemStack.NBTCOMPRESSEDSTREAMTOOLS.getClassz() - .getMethod("a", localClass, OutputStream.class) - .invoke(null, localObject1, localByteArrayOutputStream); - } catch (Exception localException) { - if (Configuration.enableDebug) { - Logger.error(localException); - } - } - String string = Base64.encode(localByteArrayOutputStream.toByteArray()); - itemStackSerialized.put(paramItemStack, string); - return string; + return Base64ItemStack.encode(paramItemStack); } public static ItemStack deserializeItemStack(String paramString) { @@ -71,128 +19,6 @@ public static ItemStack deserializeItemStack(String paramString) { return null; } - if (MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.20.5"))) { - return Base64ItemStack.decode(paramString); - } - - ByteArrayInputStream localByteArrayInputStream = null; - try { - localByteArrayInputStream = new ByteArrayInputStream(Base64.decode(paramString)); - } catch (Exception ignored) { - } - - Class localClass1 = EnumReflectionItemStack.NBTTAGCOMPOUND.getClassz(); - Class localClass2 = EnumReflectionItemStack.ITEMSTACK.getClassz(); - Object localObject1 = null; - ItemStack localItemStack = null; - Object localObject2 = null; - try { - if (NmsVersion.nmsVersion == NmsVersion.V_1_20_4) { - - DataInputStream datainputstream = new DataInputStream( - new BufferedInputStream(new GZIPInputStream(localByteArrayInputStream))); - localObject1 = EnumReflectionItemStack.NBTCOMPRESSEDSTREAMTOOLS.getClassz() - .getMethod("a", new Class[]{DataInput.class}).invoke(null, datainputstream); - } else { - - localObject1 = EnumReflectionItemStack.NBTCOMPRESSEDSTREAMTOOLS.getClassz() - .getMethod("a", new Class[]{InputStream.class}) - .invoke(null, localByteArrayInputStream); - } - - if (NMS_VERSION == NmsVersion.V_1_11 || NMS_VERSION == NmsVersion.V_1_12) { - Constructor localConstructor = localClass2.getConstructor(localClass1); - localObject2 = localConstructor.newInstance(localObject1); - } else if (MINECRAFT_VERSION.isAtMost(MinecraftVersion.parse("1.13"))) { - localObject2 = localClass2.getMethod("a", new Class[]{localClass1}).invoke(null, - localObject1); - } else { - localObject2 = localClass2.getMethod("createStack", new Class[]{localClass1}).invoke(null, - localObject1); - } - - localItemStack = (ItemStack) EnumReflectionItemStack.CRAFTITEMSTACK.getClassz() - .getMethod("asBukkitCopy", new Class[]{localClass2}).invoke(null, new Object[]{localObject2}); - } catch (Exception localException) { - // Logger.error(localException); - } - if (localItemStack != null && !itemStackSerialized.containsKey(localItemStack)) - itemStackSerialized.put(localItemStack, paramString); - return localItemStack; - + return Base64ItemStack.decode(paramString); } - - public enum EnumReflectionItemStack { - - ITEMSTACK("ItemStack", "net.minecraft.world.item.ItemStack"), - - CRAFTITEMSTACK("inventory.CraftItemStack", true), - - NBTCOMPRESSEDSTREAMTOOLS("NBTCompressedStreamTools", "net.minecraft.nbt.NBTCompressedStreamTools"), - - NBTTAGCOMPOUND("NBTTagCompound", "net.minecraft.nbt.NBTTagCompound"), - - ; - - private final String oldClassName; - private final String newClassName; - private final boolean isBukkit; - - /** - * @param oldClassName - * @param newClassName - * @param isBukkit - */ - EnumReflectionItemStack(String oldClassName, String newClassName, boolean isBukkit) { - this.oldClassName = oldClassName; - this.newClassName = newClassName; - this.isBukkit = isBukkit; - } - - /** - * @param oldClassName - * @param newClassName - */ - EnumReflectionItemStack(String oldClassName, String newClassName) { - this(oldClassName, newClassName, false); - } - - /** - * @param oldClassName - */ - EnumReflectionItemStack(String oldClassName) { - this(oldClassName, null, false); - } - - /** - * @param oldClassName - * @param isBukkit - */ - EnumReflectionItemStack(String oldClassName, boolean isBukkit) { - this(oldClassName, null, isBukkit); - } - - /** - * Create class - * - * @return class - */ - public Class getClassz() { - String nmsPackage = Bukkit.getServer().getClass().getPackage().getName(); - String nmsVersion = nmsPackage.replace(".", ",").split(",")[3]; - String var3 = NMSUtils.isNewNMSVersion() - ? this.isBukkit ? "org.bukkit.craftbukkit." + nmsVersion + "." + this.oldClassName - : this.newClassName - : (this.isBukkit ? "org.bukkit.craftbukkit." : "net.minecraft.server.") + nmsVersion + "." - + this.oldClassName; - Class localClass = null; - try { - localClass = Class.forName(var3); - } catch (ClassNotFoundException localClassNotFoundException) { - Logger.error(localClassNotFoundException); - } - return localClass; - } - } - } diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NMSProvider.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NMSProvider.java deleted file mode 100644 index 9354abbb5..000000000 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NMSProvider.java +++ /dev/null @@ -1,61 +0,0 @@ -package fr.maxlego08.menu.common.utils.nms; - -import fr.maxlego08.menu.api.utils.ReflectionsCache; -import fr.maxlego08.menu.nms.NMSHandler; -import fr.maxlego08.menu.nms.NMSVersion; -import fr.maxlego08.menu.api.utils.version.MinecraftVersion; -import fr.maxlego08.menu.zcore.logger.Logger; -import org.bukkit.Bukkit; -import org.bukkit.plugin.java.JavaPlugin; -import org.reflections.Reflections; - -import java.util.Set; - -public class NMSProvider { - - private static NMSHandler handler; - - public static NMSHandler getHandler() { - if (handler == null) { - handler = loadHandler(); - } - return handler; - } - - private static NMSHandler loadHandler() { - MinecraftVersion current = MinecraftVersion.getCurrentVersion(); - JavaPlugin plugin = (JavaPlugin) Bukkit.getPluginManager().getPlugin("zMenu"); - if (plugin == null) return null; - - Reflections reflections = ReflectionsCache.getInstance().getOrCreate(plugin, "fr.maxlego08.menu.nms"); - Set> candidates = reflections.getTypesAnnotatedWith(NMSVersion.class); - - Class bestMatch = null; - MinecraftVersion bestVersion = null; - - for (Class clazz : candidates) { - if (!NMSHandler.class.isAssignableFrom(clazz)) continue; - - NMSVersion annotation = clazz.getAnnotation(NMSVersion.class); - MinecraftVersion required = MinecraftVersion.parse(annotation.value()); - - if (current.isAtLeast(required)) { - if (bestVersion == null || required.isAfter(bestVersion)) { - bestVersion = required; - bestMatch = (Class) clazz; - } - } - } - - if (bestMatch != null) { - try { - return bestMatch.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - Logger.info("Could not instantiate NMS handler " + bestMatch.getName() + ": " + e.getMessage()); - } - } - - return null; - } -} - diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NMSUtils.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NMSUtils.java deleted file mode 100644 index 90642df9b..000000000 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NMSUtils.java +++ /dev/null @@ -1,150 +0,0 @@ -package fr.maxlego08.menu.common.utils.nms; - -import org.bukkit.Bukkit; - -public class NMSUtils { - - /** - * Get minecraft serveur value - * - * @return value - */ - public static double getNMSVersion() { - if (version != 0) - return version; - try { - String var1 = Bukkit.getServer().getClass().getPackage().getName(); - String[] arrayOfString = var1.replace(".", ",").split(",")[3].split("_"); - String var2 = arrayOfString[0].replace("v", ""); - String var3 = arrayOfString[1]; - return version = Double.parseDouble(var2 + "." + var3); - } catch (Exception ignored) { - return 1.20; - } - } - - /** - * Check if minecraft value has shulker - * - * @return boolean - */ - public static boolean hasShulker() { - return !isOneHand(); - } - - /** - * Check if minecraft value has barrel - * - * @return booleab - */ - public static boolean hasBarrel() { - final double version = getNMSVersion(); - return !(version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.11 - || version == 1.12 || version == 1.13); - } - - /** - * check if value is granther than 1.13 - * - * @return boolean - */ - public static boolean isNewVersion() { - return !isOldVersion(); - } - - /** - * Check if value has one hand - * - * @return boolean - */ - public static boolean isOneHand() { - return getNMSVersion() == 1.7 || getNMSVersion() == 1.8; - } - - /** - * Check is value is minecraft 1.7 - * - * @return boolean - */ - public static boolean isVeryOldVersion() { - return getNMSVersion() == 1.7; - } public static double version = getNMSVersion(); - - /** - * Check if value has itemmeta unbreakable - * - * @return boolean - */ - public static boolean isUnbreakable() { - return version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10; - } - - /** - * Check if value is old value of minecraft with old material system - * - * @return boolean - */ - public static boolean isOldVersion() { - return version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.12 - || version == 1.11; - } - - /** - * Check if server vesion is new value - * - * @return boolean - */ - public static boolean isNewNMSVersion() { - final double version = getNMSVersion(); - return !(version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.11 - || version == 1.12 || version == 1.13 || version == 1.14 || version == 1.15 || version == 1.16); - } - - /** - * Allows to check if the value has the colors in hex - * - * @return boolean - */ - public static boolean isHexColor() { - return !(version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.11 - || version == 1.12 || version == 1.13 || version == 1.14 || version == 1.15); - } - - /** - * Allows to check if the value has the colors in hex - * - * @return boolean - */ - public static boolean isComponentColor() { - return !(version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.11 - || version == 1.12 || version == 1.13 || version == 1.14 || version == 1.15 || version == 1.16); - } - - /** - * Check if server value is new value - * - * @return boolean - */ - public static boolean isNewNBTVersion() { - final double version = getNMSVersion(); - return !(version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.11 - || version == 1.12 || version == 1.13 || version == 1.14 || version == 1.15 || version == 1.16 - || version == 1.17); - } - - /** - * Check if server value is new value - * - * @return boolean - */ - public static boolean isNewHeadApi() { - final double version = getNMSVersion(); - return !(version == 1.7 || version == 1.8 || version == 1.9 || version == 1.10 || version == 1.11 - || version == 1.12 || version == 1.13 || version == 1.14 || version == 1.15 || version == 1.16 - || version == 1.17 || version == 1.18 || version == 1.19); - } - - - - -} diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NmsVersion.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NmsVersion.java deleted file mode 100644 index db2e67b53..000000000 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/nms/NmsVersion.java +++ /dev/null @@ -1,334 +0,0 @@ -package fr.maxlego08.menu.common.utils.nms; - -import fr.maxlego08.menu.api.utils.version.MinecraftVersion; -import fr.maxlego08.menu.zcore.logger.Logger; -import org.bukkit.Bukkit; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public enum NmsVersion { - - V_1_8_8(188), - V_1_9(190), - V_1_10(1100), - V_1_11(1110), - V_1_12(1120), - V_1_12_2(1122), - V_1_13(1130), - V_1_13_1(1131), - V_1_13_2(1132), - V_1_14(1140), - V_1_14_1(1141), - V_1_14_2(1142), - V_1_14_3(1143), - V_1_14_4(1144), - V_1_15(1150), - V_1_15_1(1151), - V_1_15_2(1152), - V_1_16(1160), - V_1_16_1(1161), - V_1_16_2(1162), - V_1_16_3(1163), - V_1_16_4(1164), - V_1_16_5(1165), - V_1_17(1170), - V_1_17_1(1171), - V_1_17_2(1172), - V_1_18(1180), - V_1_18_1(1181), - V_1_18_2(1182), - V_1_19(1190), - V_1_19_1(1191), - V_1_19_2(1192), - V_1_20(1200), - V_1_20_1(1201), - V_1_20_2(1202), - V_1_20_3(1203), - V_1_20_4(1204), - V_1_20_5(1205), - V_1_20_6(1206), - V_1_21(1210), - V_1_21_1(1211), - V_1_21_2(1212), - V_1_21_3(1213), - V_1_21_4(1214), - V_1_21_5(1215), - V_1_21_6(1216), - V_1_21_7(1217), - V_1_21_8(1218), - V_1_21_9(1219), - V_1_21_10(12110), - V_1_21_11(12111), - - UNKNOWN(Integer.MAX_VALUE); - - public static final NmsVersion nmsVersion = getNmsVersion(); - private final int version; - - NmsVersion(int version) { - this.version = version; - } - - /** - * Gets the current version of the Bukkit server. - * - * @return The NmsVersion instance corresponding to the current version. - * @deprecated Use {@link MinecraftVersion#getCurrentVersion()} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public static NmsVersion getCurrentVersion() { - return nmsVersion; - } - - private static NmsVersion getNmsVersion() { - Matcher matcher = Pattern.compile("(?\\d+\\.\\d+)(?\\.\\d+)?").matcher(Bukkit.getBukkitVersion()); - int currentVersion = matcher.find() ? Integer.parseInt(matcher.group("version").replace(".", "") + (matcher.group("patch") != null ? matcher.group("patch").replace(".", "") : "0")) : 0; - - NmsVersion highestSupportedVersionEnum = V_1_8_8; - for (NmsVersion value : values()) { - if (value != UNKNOWN && value.version > highestSupportedVersionEnum.version) { - highestSupportedVersionEnum = value; - } - } - - if (currentVersion > highestSupportedVersionEnum.version) { - Logger.info(String.format( - "Running Minecraft %s (newer than highest supported version %s). " + - "Please report this version to help us add support. " + - "Check for plugin updates if you experience issues.", - currentVersion, - highestSupportedVersionEnum.name() - ), Logger.LogType.WARNING); - return UNKNOWN; - } - - NmsVersion closest = V_1_12_2; - int smallestDifference = Integer.MAX_VALUE; - for (NmsVersion value : values()) { - if (value == UNKNOWN) continue; - int difference = Math.abs(value.version - currentVersion); - if (difference < smallestDifference) { - smallestDifference = difference; - closest = value; - } - } - return closest; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.18.1"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean hasPlayerProfiles() { - return this.version >= NmsVersion.V_1_18_1.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.17"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean hasObfuscatedNames() { - return this.version >= NmsVersion.V_1_17.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.16.5"))} instead (combined with your Paper check). - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isComponent(boolean isPaper) { - return isPaper && this.version >= NmsVersion.V_1_16_5.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isBefore(MinecraftVersion.parse("1.13"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isItemLegacy() { - return this.version < NmsVersion.V_1_13.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.14"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isPdcVersion() { - return this.version >= NmsVersion.V_1_14.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtMost(MinecraftVersion.parse("1.12"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isSkullOwnerLegacy() { - return this.version <= NmsVersion.V_1_12.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.14"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isCustomModelData() { - return this.version >= NmsVersion.V_1_14.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.16"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isHexVersion() { - return this.version >= NmsVersion.V_1_16.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAfter(MinecraftVersion.parse("1.8.8"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isAttributeVersion() { - return this.version != NmsVersion.V_1_8_8.version; - } - - /** - * @deprecated Use {@link MinecraftVersion#getMinor()} or comparisons via {@link MinecraftVersion} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public int getVersion() { - return this.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.20.5"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isAttributItemStack() { - return this.version >= NmsVersion.V_1_20_5.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().equals(MinecraftVersion.parse("1.8.8"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isOneHand() { - return this.version == NmsVersion.V_1_8_8.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.14"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isBarrel() { - return this.version >= V_1_14.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.9"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isShulker() { - return this.version >= V_1_9.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.13"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isNewMaterial() { - return this.version >= V_1_13.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.18"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isNewNBTVersion() { - return this.version >= V_1_18.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.20"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isNewHeadApi() { - return this.version >= V_1_20.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.17"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isNewNMSVersion() { - return this.version >= V_1_17.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.11"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean is1_11OrNewer() { - return this.version >= V_1_11.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.12"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean is1_12OrNewer() { - return this.version >= V_1_12.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isNewItemStackAPI() { - return this.version >= V_1_21.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21.4"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isNewItemModelAPI() { - return this.version >= V_1_21_4.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21.7"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean isDialogsVersion() { - return this.version >= V_1_21_7.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21.2"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean is1_21_2OrNewer() { - return this.version >= V_1_21_2.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21.5"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean is1_21_5OrNewer() { - return this.version >= V_1_21_5.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21.9"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean is1_21_9OrNewer() { - return this.version >= V_1_21_9.version; - } - - /** - * @deprecated Use {@code MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21.11"))} instead. - */ - @Deprecated(since = "1.1.1.4", forRemoval = true) - public boolean is1_21_11OrNewer() { - return this.version >= V_1_21_11.version; - } -} \ No newline at end of file diff --git a/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java b/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java index ec6c4241e..569ce526a 100644 --- a/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java +++ b/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java @@ -48,12 +48,28 @@ public abstract class VInventory extends ZUtils implements Cloneable, BaseInvent private ClearInvType clearInvType = ClearInvType.DEFAULT; private boolean isClose = false; + private boolean frozen = false; @Override public boolean isClose() { return this.isClose; } + @Override + public boolean isFrozen() { + return this.frozen; + } + + /** + * Freezes or unfreezes this inventory. A frozen inventory ignores every click, which is used + * while the opening animation plays. + * + * @param frozen whether the inventory must ignore clicks + */ + public void setFrozen(boolean frozen) { + this.frozen = frozen; + } + public int getId() { return this.id; } @@ -214,11 +230,6 @@ public Object[] getArgs() { return this.args; } - @Override - public @NonNull Inventory getSpigotInventory() { - return this.inventory; - } - public String getGuiName() { return this.guiName; } diff --git a/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java b/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java index 388bb618b..22dd4cdce 100644 --- a/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java +++ b/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java @@ -2,18 +2,37 @@ import com.tcoded.folialib.wrapper.task.WrappedTask; import fr.maxlego08.menu.api.Inventory; -import fr.maxlego08.menu.api.InventoryManager; import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.animation.compile.AnimationCompiler; +import fr.maxlego08.menu.animation.compile.AnimationStep; +import fr.maxlego08.menu.animation.compile.CompiledAnimation; +import fr.maxlego08.menu.animation.compile.StepAction; import fr.maxlego08.menu.api.MenuPlugin; +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationContext; +import fr.maxlego08.menu.api.animation.slot.AnimationHistory; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayMode; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.animation.slot.AnimationSlots; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationVariant; +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.idle.ButtonIdleAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.idle.IdleFrame; import fr.maxlego08.menu.api.button.Button; +import fr.maxlego08.menu.api.configuration.Configuration; import fr.maxlego08.menu.api.engine.InventoryEngine; import fr.maxlego08.menu.api.engine.InventoryResult; import fr.maxlego08.menu.api.engine.ItemButton; +import fr.maxlego08.menu.api.engine.OpenCause; +import fr.maxlego08.menu.api.event.events.AnimationEndEvent; +import fr.maxlego08.menu.api.event.events.AnimationStartEvent; import fr.maxlego08.menu.api.exceptions.InventoryOpenException; import fr.maxlego08.menu.api.inventory.ChestInventory; import fr.maxlego08.menu.api.inventory.ContainerInventory; import fr.maxlego08.menu.api.pattern.Pattern; -import fr.maxlego08.menu.api.configuration.Configuration; +import fr.maxlego08.menu.api.requirement.Action; import fr.maxlego08.menu.api.requirement.RefreshRequirement; import fr.maxlego08.menu.api.requirement.Requirement; import fr.maxlego08.menu.api.utils.Placeholders; @@ -27,10 +46,15 @@ import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jspecify.annotations.NonNull; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; /** @@ -38,13 +62,25 @@ */ public class InventoryDefault extends VInventory implements InventoryEngine { - private final Map timers = new ConcurrentHashMap<>(); + private final Map timers = new ConcurrentHashMap<>(); + private final Map buttonsByAnimationSlot = new ConcurrentHashMap<>(); + private final Set slotsOwnedByAnimation = ConcurrentHashMap.newKeySet(); + private final Map idleTasksBySlot = new ConcurrentHashMap<>(); + private final Map idleCycleBySlot = new ConcurrentHashMap<>(); + private final Map idleCyclesAwaitingRebuild = new ConcurrentHashMap<>(); + private volatile boolean useSharedIdleTimer; + private final Set sharedIdleCycles = ConcurrentHashMap.newKeySet(); + private final Object sharedIdleLock = new Object(); + private WrappedTask sharedIdleTimer; + + private volatile RunningAnimation runningAnimation; + private volatile Placeholders openPlaceholders = new Placeholders(); private PerformanceDebug perfDebug; private Inventory inventory; private List oldInventories = new ArrayList<>(); private List