diff --git a/build.gradle.kts b/build.gradle.kts index c92f043..581414f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { testImplementation("com.sk89q.worldguard:worldguard-bukkit:7.0.6") compileOnly("org.projectlombok:lombok:1.18.38") annotationProcessor("org.projectlombok:lombok:1.18.38") + compileOnly("net.dmulloy2:ProtocolLib:5.4.0") testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.108.0") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") @@ -60,11 +61,24 @@ tasks.processResources { } tasks.shadowJar { - archiveClassifier.set("") - minimize() + archiveClassifier.set("") // Mengganti JAR asli dengan Fat JAR + + // Relokasi library agar tidak konflik dengan plugin lain relocate("me.orineko.pluginspigottools", "me.orineko.thirstbar.tools") + relocate("com.cryptomorin.xseries","me.orineko.thirstbar.xseries") + relocate("de.tr7zw.changeme.nbtapi", "me.orineko.thirstbar.nbtapi") + relocate("net.objecthunter.exp4j", "me.orineko.thirstbar.exp4j") + + // Penting: Menggabungkan file layanan dan menangani file duplikat + mergeServiceFiles() + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } +tasks.build { + dependsOn(tasks.shadowJar) +} + + tasks.test { useJUnitPlatform() jvmArgs("-Djdk.net.URLClassPath.disableClassPathURLCheck=true") diff --git a/src/main/java/me/orineko/thirstbar/ThirstBar.java b/src/main/java/me/orineko/thirstbar/ThirstBar.java index dab001c..3ed508b 100644 --- a/src/main/java/me/orineko/thirstbar/ThirstBar.java +++ b/src/main/java/me/orineko/thirstbar/ThirstBar.java @@ -17,6 +17,7 @@ import me.orineko.thirstbar.api.worldguardapi.WorldGuardApi; import me.orineko.thirstbar.manager.file.ConfigData; import me.orineko.thirstbar.manager.file.MessageData; +import me.orineko.thirstbar.manager.item.ItemData; import me.orineko.thirstbar.manager.item.ItemDataList; import me.orineko.thirstbar.manager.player.PlayerData; import me.orineko.thirstbar.manager.player.PlayerDataList; @@ -27,6 +28,8 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.FurnaceRecipe; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.plugin.java.JavaPlugin; @@ -35,6 +38,7 @@ import javax.annotation.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Getter @@ -65,7 +69,6 @@ public void onLoad() { registerFlag(); } - @SuppressWarnings("deprecation") @Override public void onEnable() { saveDefaultConfig(); @@ -123,18 +126,17 @@ public void onEnable() { } bottle.setItemMeta(meta); ItemStack potionRawItem = MethodDefault.getItemAllVersion("POTION"); - FurnaceRecipe furnaceRecipe; - if (getVersionBukkit() < 16) { - // noinspection deprecation - furnaceRecipe = new FurnaceRecipe(bottle, potionRawItem.getType()); - } else { - NamespacedKey key = new NamespacedKey(this, "raw_water_furnace"); - try { - Bukkit.removeRecipe(key); - } catch (NoSuchMethodError | Exception ignore) {} - furnaceRecipe = new FurnaceRecipe(key, bottle, - potionRawItem.getType(), ConfigData.CUSTOM_FURNACE_EXP, ConfigData.CUSTOM_FURNACE_COOKING_TIME); - } + NamespacedKey key = new NamespacedKey(this, "raw_water_furnace"); + try { + Bukkit.removeRecipe(key); + } catch (NoSuchMethodError | Exception ignore) {} + FurnaceRecipe furnaceRecipe = new FurnaceRecipe( + key, + bottle, + potionRawItem.getType(), + ConfigData.CUSTOM_FURNACE_EXP, + ConfigData.CUSTOM_FURNACE_COOKING_TIME + ); try { Bukkit.addRecipe(furnaceRecipe); } catch (Exception ignored) {} @@ -174,6 +176,8 @@ public void renewData() { itemDataList = new ItemDataList(); itemDataList.loadData(); + registerCustomCookRecipes(); + registerCustomCraftRecipes(); stageList = new StageList(); // Clean up old player data BEFORE creating new list to prevent leaks @@ -257,4 +261,101 @@ private void checkForUpdate() { public static ThirstBar getInstance() { return plugin; } + + private void registerCustomCookRecipes() { + List toRemove = new ArrayList<>(); + Bukkit.recipeIterator().forEachRemaining(recipe -> { + if (recipe instanceof FurnaceRecipe) { + NamespacedKey key = ((FurnaceRecipe) recipe).getKey(); + if (key != null && key.getNamespace().equalsIgnoreCase(getName().toLowerCase()) + && key.getKey().startsWith("custom_cook_")) { + toRemove.add(key); + } + } + }); + toRemove.forEach(Bukkit::removeRecipe); + + for (ItemData itemData : itemDataList.getDataList()) { + if (itemData.getCookType() == null || !itemData.getCookType().equalsIgnoreCase("cooking")) continue; + if (itemData.getCookReplace() == null || itemData.getCookReplace().trim().isEmpty()) continue; + if (itemData.getItemStack() == null) continue; + ItemData target = itemDataList.getData(itemData.getCookReplace()); + if (target == null || target.getItemStack() == null) continue; + + NamespacedKey key = new NamespacedKey(this, "custom_cook_" + itemData.getName().toLowerCase()); + FurnaceRecipe recipe = new FurnaceRecipe( + key, + target.getItemStack().clone(), + new RecipeChoice.MaterialChoice(itemData.getItemStack().getType()), + itemData.getCookExp(), + Math.max(1, itemData.getCookTime()) + ); + try { + Bukkit.addRecipe(recipe); + } catch (Exception ignore) {} + } + } + + private void registerCustomCraftRecipes() { + List toRemove = new ArrayList<>(); + Bukkit.recipeIterator().forEachRemaining(recipe -> { + if (recipe instanceof ShapedRecipe) { + NamespacedKey key = ((ShapedRecipe) recipe).getKey(); + if (key != null && key.getNamespace().equalsIgnoreCase(getName().toLowerCase()) + && key.getKey().startsWith("custom_craft_")) { + toRemove.add(key); + } + } + }); + toRemove.forEach(Bukkit::removeRecipe); + + for (ItemData itemData : itemDataList.getDataList()) { + if (itemData.getCraftingType() == null || !itemData.getCraftingType().equalsIgnoreCase("crafting")) continue; + if (itemData.getItemStack() == null) continue; + List recipeRows = itemData.getCraftingRecipe(); + if (recipeRows == null || recipeRows.size() != 3) continue; + + String[] shape = new String[3]; + boolean validShape = true; + for (int i = 0; i < 3; i++) { + String[] parts = recipeRows.get(i).split("\\|"); + if (parts.length != 3) { + validShape = false; + break; + } + StringBuilder row = new StringBuilder(); + for (String part : parts) { + String token = part.trim(); + if (token.length() != 1) { + validShape = false; + break; + } + row.append(token.charAt(0)); + } + if (!validShape) break; + shape[i] = row.toString(); + } + if (!validShape) continue; + + NamespacedKey key = new NamespacedKey(this, "custom_craft_" + itemData.getName().toLowerCase()); + ShapedRecipe shaped = new ShapedRecipe(key, itemData.getItemStack().clone()); + shaped.shape(shape[0], shape[1], shape[2]); + + Map vars = itemData.getCraftingVariable(); + if (vars == null) vars = java.util.Collections.emptyMap(); + for (Map.Entry entry : vars.entrySet()) { + Character ch = entry.getKey(); + if (ch == null) continue; + String materialName = entry.getValue(); + if (materialName == null || materialName.trim().isEmpty() || materialName.equalsIgnoreCase("NONE")) continue; + Material mat = Material.matchMaterial(materialName.trim().toUpperCase()); + if (mat == null || mat == Material.AIR) continue; + shaped.setIngredient(ch, mat); + } + + try { + Bukkit.addRecipe(shaped); + } catch (Exception ignore) {} + } + } } diff --git a/src/main/java/me/orineko/thirstbar/api/ThirstBarExpansion.java b/src/main/java/me/orineko/thirstbar/api/ThirstBarExpansion.java index 453ebb7..eee34ab 100644 --- a/src/main/java/me/orineko/thirstbar/api/ThirstBarExpansion.java +++ b/src/main/java/me/orineko/thirstbar/api/ThirstBarExpansion.java @@ -5,13 +5,22 @@ import me.orineko.thirstbar.manager.file.ConfigData; import me.orineko.thirstbar.manager.player.PlayerData; import me.orineko.thirstbar.manager.stage.Stage; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.lang.reflect.Method; import java.util.List; public class ThirstBarExpansion extends PlaceholderExpansion { + private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacySection(); @Override public @NotNull String getIdentifier() { @@ -69,7 +78,111 @@ public class ThirstBarExpansion extends PlaceholderExpansion { return ConfigData.ACTION_BAR_DISABLE_TEXT(playerData.getThirst(), playerData.getThirstMax(), playerData.getReduceTotal(player), playerData.getThirstTime() / 20.0); } return ConfigData.ACTION_BAR_TEXT(playerData.getThirst(), playerData.getThirstMax(), playerData.getReduceTotal(player), playerData.getThirstTime() / 20.0); + case "mainhand_item_name": + return getMainHandItemName(player); } return null; } + + private String getMainHandItemName(@NotNull Player player) { + ItemStack itemStack = player.getInventory().getItemInMainHand(); + + if (itemStack.getType() == Material.AIR) { + return "AIR"; + } + + ItemMeta meta = itemStack.getItemMeta(); + + if (meta != null) { + + // PRIORITAS 1 + // minecraft:custom_name + if (meta.hasCustomName()) { + Component customName = meta.customName(); + + if (customName != null) { + String serialized = LEGACY_SERIALIZER.serialize(customName); + + if (!serialized.isEmpty()) { + return serialized; + } + } + } + + // PRIORITAS 2 + // minecraft:item_name + String componentString = meta.getAsComponentString(); + + String itemName = extractItemName(componentString); + + if (itemName != null && !itemName.isEmpty()) { + return itemName; + } + + // PRIORITAS 3 + // legacy api compatibility + try { + String legacyName = meta.getItemName(); + + if (legacyName != null && !legacyName.isEmpty()) { + return legacyName; + } + } catch (Throwable ignored) { + } + } + + // PRIORITAS TERAKHIR + // fallback ke material name + return toTitleCase( + itemStack.getType() + .name() + .toLowerCase() + .replace("_", " ") + ); + } + + private @Nullable String extractItemName(@NotNull String componentString) { + try { + + String key = "minecraft:item_name='"; + + int start = componentString.indexOf(key); + + if (start == -1) { + return null; + } + + start += key.length(); + + int end = componentString.indexOf("'", start); + + if (end == -1) { + return null; + } + + String rawJson = componentString.substring(start, end); + + Component component = GsonComponentSerializer.gson().deserialize(rawJson); + + return LEGACY_SERIALIZER.serialize(component); + + } catch (Throwable ignored) { + } + + return null; + } + + private String toTitleCase(@NotNull String input) { + if (input.isEmpty()) return input; + String[] words = input.split(" "); + StringBuilder out = new StringBuilder(input.length()); + for (int i = 0; i < words.length; i++) { + String word = words[i]; + if (word.isEmpty()) continue; + if (out.length() > 0) out.append(' '); + out.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) out.append(word.substring(1)); + } + return out.toString(); + } } diff --git a/src/main/java/me/orineko/thirstbar/listener/ThirstListener.java b/src/main/java/me/orineko/thirstbar/listener/ThirstListener.java index b04dcda..add6151 100644 --- a/src/main/java/me/orineko/thirstbar/listener/ThirstListener.java +++ b/src/main/java/me/orineko/thirstbar/listener/ThirstListener.java @@ -1,5 +1,11 @@ package me.orineko.thirstbar.listener; +import com.comphenix.protocol.PacketType; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.events.ListenerPriority; +import com.comphenix.protocol.events.PacketContainer; +import com.comphenix.protocol.events.PacketEvent; +import com.comphenix.protocol.events.PacketAdapter; import me.orineko.pluginspigottools.nbtapi.NBTItem; import me.orineko.pluginspigottools.utils.MethodDefault; import me.orineko.thirstbar.ThirstBar; @@ -10,8 +16,14 @@ import me.orineko.thirstbar.manager.player.PlayerData; import me.orineko.thirstbar.manager.stage.StageConfig; import me.orineko.thirstbar.manager.stage.StageList; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.*; import org.bukkit.block.Block; +import org.bukkit.block.data.Levelled; import org.bukkit.entity.ArmorStand; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -20,11 +32,19 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.player.*; +import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Vector; import javax.annotation.Nonnull; @@ -35,7 +55,15 @@ public class ThirstListener implements Listener { // public static final HashMap armorStandMap = new HashMap<>(); private final List delayClickMap = new ArrayList<>(); private final List delayMoveMap = new ArrayList<>(); + private final List consumeCooldownMap = new ArrayList<>(); private final String keyPotionRaw = "RawWater"; + private final NamespacedKey usageKey = new NamespacedKey(ThirstBar.getInstance(), "custom_item_usage"); + private final Map drinkingAnimationStateMap = new HashMap<>(); + private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacySection(); + + public ThirstListener() { + registerProtocolItemPackets(); + } @EventHandler public void onJoin(PlayerJoinEvent e) { @@ -85,6 +113,9 @@ public void onJoin(PlayerJoinEvent e) { @EventHandler public void onQuit(PlayerQuitEvent e) { Player player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + drinkingAnimationMap.remove(uuid); + drinkingAnimationStateMap.remove(uuid); PlayerData playerData = ThirstBar.getInstance().getPlayerDataList().addData(player); playerData.setDisplayBossBar(false, player); playerData.getBossBar().removePlayer(player); @@ -128,6 +159,16 @@ public void onRespawn(PlayerRespawnEvent e) { @EventHandler public void onEat(PlayerItemConsumeEvent e) { Player player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + + // Real consume finished for our temporary drink item + if (drinkingAnimationMap.contains(uuid)) { + e.setCancelled(true); + finishDrinkingAnimation(player, true); + return; + } + + // Block consume events that fire while our drink animation is running ItemStack itemHand = player.getItemInHand(); if (itemHand.getType().equals(Material.AIR)) return; PlayerData playerData = ThirstBar.getInstance().getPlayerDataList().addData(player); @@ -138,7 +179,7 @@ public void onEat(PlayerItemConsumeEvent e) { NBTItem nbtItem = new NBTItem(itemHand); tagRawWater = nbtItem.getString(keyPotionRaw); } catch (Throwable ignore) {} - + if (tagRawWater != null && tagRawWater.equals("true")) { StageConfig stageWater = ThirstBar.getInstance().getStageList().getStageConfig(StageList.KeyConfig.WATER); if (stageWater != null) { @@ -163,6 +204,14 @@ public void onEat(PlayerItemConsumeEvent e) { ItemData itemData = ThirstBar.getInstance().getItemDataList().getData(itemHand); if (itemData == null) return; + if (itemData.getConsumeType() != null && itemData.getConsumeType().equalsIgnoreCase("on_player")) { + e.setCancelled(true); + if (consumeCooldownMap.contains(player.getUniqueId())) return; + ItemStack consumed = e.getItem(); + player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_DRINK, 1.0f, 1.0f); + startDrinkingAnimation(player, itemData, consumed.clone(), playerData); + return; + } double value = itemData.getValue(); if ((ConfigData.STOP_DRINKING && !checkFoodHaveEffect(itemHand)) && playerData.getThirst() >= playerData.getThirstMax()) { @@ -373,6 +422,7 @@ public void onClickEntity(PlayerInteractAtEntityEvent e) { @EventHandler(priority = EventPriority.HIGH) public void onClick(PlayerInteractEvent e) { if(e.isCancelled()) return; + if (e.getHand() != null && e.getHand() != EquipmentSlot.HAND) return; if (!(e.getAction().equals(Action.RIGHT_CLICK_AIR) || e.getAction().equals(Action.RIGHT_CLICK_BLOCK))) return; Player player = e.getPlayer(); PlayerData playerData = ThirstBar.getInstance().getPlayerDataList().addData(player); @@ -461,6 +511,39 @@ public void onClick(PlayerInteractEvent e) { Bukkit.getScheduler().scheduleSyncDelayedTask(ThirstBar.getInstance(), () -> player.setFoodLevel(20)); } + @EventHandler + public void onFurnaceStartSmelt(FurnaceStartSmeltEvent e) { + ItemStack source = e.getSource(); + ItemData itemData = ThirstBar.getInstance().getItemDataList().getData(source); + if (itemData == null) { + if (isAnyCustomCookingMaterial(source.getType())) e.setTotalCookTime(Integer.MAX_VALUE); + return; + } + if (itemData.getCookType() == null || !itemData.getCookType().equalsIgnoreCase("cooking")) { + if (isAnyCustomCookingMaterial(source.getType())) e.setTotalCookTime(Integer.MAX_VALUE); + } + } + + @EventHandler + public void onFurnaceSmelt(FurnaceSmeltEvent e) { + ItemStack source = e.getSource(); + ItemData itemData = ThirstBar.getInstance().getItemDataList().getData(source); + if (itemData == null) { + if (isAnyCustomCookingMaterial(source.getType())) e.setCancelled(true); + return; + } + if (itemData.getCookType() == null || !itemData.getCookType().equalsIgnoreCase("cooking")) { + if (isAnyCustomCookingMaterial(source.getType())) e.setCancelled(true); + return; + } + if (itemData.getCookReplace() == null || itemData.getCookReplace().trim().isEmpty()) return; + ItemData replaceData = ThirstBar.getInstance().getItemDataList().getData(itemData.getCookReplace()); + if (replaceData == null || replaceData.getItemStack() == null) return; + ItemStack result = replaceData.getItemStack().clone(); + transferUsage(source, result); + e.setResult(result); + } + @EventHandler public void onChangeWorld(PlayerChangedWorldEvent e) { Player player = e.getPlayer(); @@ -496,4 +579,609 @@ private boolean checkFoodHaveEffect(@Nonnull ItemStack itemStack) { } return false; } + + private boolean isLookingAtWater(@Nonnull Player player) { + try { + Class fluidCollisionModeClass = Class.forName("org.bukkit.FluidCollisionMode"); + Object always = Enum.valueOf((Class) fluidCollisionModeClass.asSubclass(Enum.class), "ALWAYS"); + Object hit = Player.class.getMethod("rayTraceBlocks", double.class, fluidCollisionModeClass) + .invoke(player, 4.5, always); + if (hit != null) { + Object block = hit.getClass().getMethod("getHitBlock").invoke(hit); + if (block instanceof Block) { + return ((Block) block).getType().name().contains("WATER"); + } + } + } catch (Throwable ignore) { + } + return ThirstBarMethod.checkSightIsWater(player); + } + + private void replaceHeldItem(@Nonnull Player player, @Nonnull String replaceName) { + replaceItemInSlot(player, player.getInventory().getHeldItemSlot(), replaceName); + } + + private void replaceItemInSlot(@Nonnull Player player, int slot, @Nonnull String replaceName) { + ItemData replaceData = ThirstBar.getInstance().getItemDataList().getData(replaceName); + if (replaceData == null || replaceData.getItemStack() == null) return; + ItemStack out = replaceData.getItemStack().clone(); + applyLocalVisualMeta(out, replaceData); + player.getInventory().setItem(slot, out); + } + + private void consumeCustomItem(@Nonnull Player player, @Nonnull PlayerData playerData, + @Nonnull ItemStack itemHand, @Nonnull ItemData itemData, int slot) { + if (ConfigData.STOP_DRINKING && playerData.getThirst() >= playerData.getThirstMax()) return; + if (itemData.getConsumeRestore() != 0) { + playerData.addThirst(itemData.getConsumeRestore()); + } else { + double value = itemData.getValue(); + if (value > 0) playerData.addThirst(value); + else if (itemData.getValuePercent() > 0) { + playerData.addThirst(playerData.getThirstMax() * (itemData.getValuePercent() / 100)); + } + } + applyWaterType(player, playerData, itemData.getConsumeWaterType()); + if (playerData.getThirst() > playerData.getThirstMax()) playerData.setThirst(playerData.getThirstMax()); + if (playerData.getThirst() < 0) playerData.setThirst(0); + if (itemData.getConsumeMaxUsage() > 0) { + int usage = increaseUsageAndUpdateBar(itemHand, itemData.getConsumeMaxUsage(), itemData.isShowDurability()); + applyLocalVisualMeta(itemHand, itemData); + player.getInventory().setItem(slot, itemHand); + if (usage >= itemData.getConsumeMaxUsage() && itemData.getConsumeReplace() != null) { + replaceItemInSlot(player, slot, itemData.getConsumeReplace()); + } + } else if (itemData.getConsumeReplace() != null) { + replaceItemInSlot(player, slot, itemData.getConsumeReplace()); + } + playerData.updateAll(player); + } + + private int increaseUsageAndUpdateBar(@Nonnull ItemStack itemStack, int maxUsage, boolean showDurability) { + ItemMeta meta = itemStack.getItemMeta(); + if (meta == null) return 0; + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + int current = pdc.has(usageKey, PersistentDataType.INTEGER) + ? pdc.get(usageKey, PersistentDataType.INTEGER) : 0; + current++; + pdc.set(usageKey, PersistentDataType.INTEGER, current); + if (showDurability && meta instanceof Damageable && itemStack.getType().getMaxDurability() > 0) { + Damageable damageable = (Damageable) meta; + int maxDurability = itemStack.getType().getMaxDurability(); + int damage = (int) Math.round((Math.min(current, maxUsage) / (double) maxUsage) * (maxDurability - 1)); + damageable.setDamage(Math.max(0, damage)); + } + itemStack.setItemMeta(meta); + return current; + } + + private void applyWaterType(@Nonnull Player player, @Nonnull PlayerData playerData, String waterType) { + if (waterType == null) return; + if (!waterType.equalsIgnoreCase("RAW_WATER")) return; + StageConfig stageWater = ThirstBar.getInstance().getStageList().getStageConfig(StageList.KeyConfig.WATER); + if (stageWater == null) return; + if (playerData.idDelayDisable != 0) { + Bukkit.getScheduler().cancelTask(playerData.idDelayDisable); + playerData.idDelayDisable = 0; + } + playerData.disableStage(player, StageList.KeyConfig.WATER); + playerData.setStage(player, stageWater); + playerData.idDelayDisable = Bukkit.getScheduler().scheduleSyncDelayedTask(ThirstBar.getInstance(), + () -> playerData.disableStage(player, StageList.KeyConfig.WATER), stageWater.getDuration()); + } + + // Tracks players currently in drink animation to prevent consume re-entry + private final java.util.Set drinkingAnimationMap = new java.util.HashSet<>(); + private static final long DRINK_ANIMATION_TICKS = 32L; + private static class DrinkingAnimationState { + private final ItemData itemData; + private final ItemStack originalHand; + private final PlayerData playerData; + private final int originHotbarSlot; + private int monitorTaskId = 0; + + private DrinkingAnimationState(@Nonnull ItemData itemData, @Nonnull ItemStack originalHand, + @Nonnull PlayerData playerData, int originHotbarSlot) { + this.itemData = itemData; + this.originalHand = originalHand; + this.playerData = playerData; + this.originHotbarSlot = originHotbarSlot; + } + } + + /** + * Plays a real drink animation by temporarily swapping the held item server-side to a + * consumable POTION (with drinking-model custom-model-data if configured), letting Minecraft + * handle the native hold-and-drink animation, then restoring the original item after + * the animation completes (~32 ticks). + * + * The consume logic is delayed to fire AFTER the animation finishes so the player + * actually sees and hears the full drinking sequence before the item is replaced. + */ + private void startDrinkingAnimation(@Nonnull Player player, @Nonnull ItemData itemData, + @Nonnull ItemStack originalHand, @Nonnull PlayerData playerData) { + UUID uuid = player.getUniqueId(); + if (drinkingAnimationMap.contains(uuid)) return; + drinkingAnimationMap.add(uuid); + int heldSlot = player.getInventory().getHeldItemSlot(); + DrinkingAnimationState state = new DrinkingAnimationState(itemData, originalHand.clone(), playerData, heldSlot); + drinkingAnimationStateMap.put(uuid, state); + + // Build the visual drinking item — must be a consumable so Minecraft plays animation + String drinkMat = itemData.getDrinkingModelMaterial(); + Material mat = null; + if (drinkMat != null && !drinkMat.isEmpty()) { + mat = Material.matchMaterial(drinkMat.toUpperCase()); + } + if (mat == null) mat = Material.POTION; + + ItemStack drinkItem = new ItemStack(mat, 1); + ItemMeta drinkMeta = drinkItem.getItemMeta(); + if (drinkMeta != null) { + int cmd = itemData.getDrinkingModelData(); + if (cmd > 0) drinkMeta.setCustomModelData(cmd); + Integer usage = null; + ItemMeta originalHandMeta = originalHand.getItemMeta(); + if (originalHandMeta != null) { + usage = originalHandMeta.getPersistentDataContainer().get(usageKey, PersistentDataType.INTEGER); + } + int currentUsage = usage == null ? 0 : usage; + applyUsageBarText(drinkMeta, itemData, currentUsage); + // Fallback if no template-based display/lore is configured + ItemStack original = itemData.getItemStack(); + if (original != null) { + ItemMeta origMeta = original.getItemMeta(); + if (origMeta != null) { + if (drinkMeta.displayName() == null && origMeta.hasDisplayName()) { + drinkMeta.displayName(LEGACY_SERIALIZER.deserialize(origMeta.getDisplayName()) + .decoration(TextDecoration.ITALIC, false)); + } + if (drinkMeta.lore() == null && origMeta.hasLore()) { + List lore = new ArrayList<>(); + for (String line : origMeta.getLore()) { + lore.add(LEGACY_SERIALIZER.deserialize(line).decoration(TextDecoration.ITALIC, false)); + } + drinkMeta.lore(lore); + } + } + } + // Make it drinkable on 1.20.5+ so native animation triggers + try { + Object food = drinkMeta.getClass().getMethod("getFood").invoke(drinkMeta); + food.getClass().getMethod("setCanAlwaysEat", boolean.class).invoke(food, true); + food.getClass().getMethod("setNutrition", int.class).invoke(food, 0); + food.getClass().getMethod("setSaturation", float.class).invoke(food, 0.0f); + try { food.getClass().getMethod("setEatSeconds", float.class).invoke(food, 1.6f); } catch (NoSuchMethodException ignore) {} + drinkMeta.getClass().getMethod("setFood", food.getClass()).invoke(drinkMeta, food); + } catch (Throwable ignore) {} + drinkItem.setItemMeta(drinkMeta); + } + + // Swap real item server-side so client sees and animates properly + player.getInventory().setItemInMainHand(drinkItem); + player.updateInventory(); + + // Monitor every tick: restore immediately when player cancels, hard-timeout as fallback + final int[] ticks = {0}; + state.monitorTaskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(ThirstBar.getInstance(), () -> { + if (!drinkingAnimationMap.contains(uuid)) { + cancelAnimationMonitor(state); + return; + } + if (!player.isOnline()) { + finishDrinkingAnimation(player, false); + return; + } + ticks[0]++; + if (ticks[0] >= (DRINK_ANIMATION_TICKS + 8L)) { + finishDrinkingAnimation(player, false); + } + }, 1L, 1L); + } + + private void finishDrinkingAnimation(@Nonnull Player player, boolean consumed) { + UUID uuid = player.getUniqueId(); + DrinkingAnimationState state = drinkingAnimationStateMap.remove(uuid); + if (state == null) { + drinkingAnimationMap.remove(uuid); + return; + } + drinkingAnimationMap.remove(uuid); + cancelAnimationMonitor(state); + if (!player.isOnline()) return; + + // Always restore original item to its original slot (not current selected slot) + player.getInventory().setItem(state.originHotbarSlot, state.originalHand.clone()); + player.updateInventory(); + + if (!consumed) return; + consumeCustomItem(player, state.playerData, state.originalHand.clone(), state.itemData, state.originHotbarSlot); + } + + private void cancelAnimationMonitor(@Nonnull DrinkingAnimationState state) { + if (state.monitorTaskId == 0) return; + Bukkit.getScheduler().cancelTask(state.monitorTaskId); + state.monitorTaskId = 0; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onChangeHeldSlot(PlayerItemHeldEvent e) { + Player player = e.getPlayer(); + if (!drinkingAnimationMap.contains(player.getUniqueId())) return; + finishDrinkingAnimation(player, false); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onDropWhileDrinking(PlayerDropItemEvent e) { + Player player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + if (!drinkingAnimationMap.contains(uuid)) return; + + DrinkingAnimationState state = drinkingAnimationStateMap.get(uuid); + if (state == null) { + drinkingAnimationMap.remove(uuid); + return; + } + + // Ensure dropped item is the original custom item, not temporary potion swap item + e.getItemDrop().setItemStack(state.originalHand.clone()); + + // Item has been dropped, so clear original slot and end animation state without consume + player.getInventory().setItem(state.originHotbarSlot, new ItemStack(Material.AIR)); + drinkingAnimationStateMap.remove(uuid); + drinkingAnimationMap.remove(uuid); + player.updateInventory(); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onInventoryClickWhileDrinking(InventoryClickEvent e) { + if (!(e.getWhoClicked() instanceof Player)) return; + Player player = (Player) e.getWhoClicked(); + if (!drinkingAnimationMap.contains(player.getUniqueId())) return; + e.setCancelled(true); + finishDrinkingAnimation(player, false); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onInventoryDragWhileDrinking(InventoryDragEvent e) { + if (!(e.getWhoClicked() instanceof Player)) return; + Player player = (Player) e.getWhoClicked(); + if (!drinkingAnimationMap.contains(player.getUniqueId())) return; + e.setCancelled(true); + finishDrinkingAnimation(player, false); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onSwapHandWhileDrinking(PlayerSwapHandItemsEvent e) { + Player player = e.getPlayer(); + if (!drinkingAnimationMap.contains(player.getUniqueId())) return; + e.setCancelled(true); + finishDrinkingAnimation(player, false); + } + + private void playDrinkAnimation(@Nonnull Player player) { + // Prefer ProtocolLib packet animation when available + if (Bukkit.getPluginManager().isPluginEnabled("ProtocolLib")) { + try { + PacketContainer packet = ProtocolLibrary.getProtocolManager().createPacket(PacketType.Play.Server.ENTITY_STATUS); + packet.getIntegers().write(0, player.getEntityId()); + packet.getBytes().write(0, (byte) 9); + ProtocolLibrary.getProtocolManager().sendServerPacket(player, packet); + return; + } catch (Exception ignore) { + } + } + + String[] candidates = {"DRINK_MILK", "DRINK"}; + for (String candidate : candidates) { + try { + EntityEffect effect = EntityEffect.valueOf(candidate); + player.playEffect(effect); + return; + } catch (IllegalArgumentException ignore) { + } + } + } + + private void registerProtocolItemPackets() { + if (!Bukkit.getPluginManager().isPluginEnabled("ProtocolLib")) return; + try { + ProtocolLibrary.getProtocolManager().addPacketListener(new PacketAdapter( + ThirstBar.getInstance(), + ListenerPriority.NORMAL, + PacketType.Play.Client.USE_ITEM, + PacketType.Play.Client.USE_ITEM_ON + ) { + @Override + public void onPacketReceiving(PacketEvent event) { + Player player = event.getPlayer(); + if (player == null || !player.isOnline()) return; + EquipmentSlot hand = readHand(event.getPacket()); + if (hand != EquipmentSlot.HAND) return; + Bukkit.getScheduler().runTask(ThirstBar.getInstance(), () -> handleUseItemPacket(player)); + } + }); + + ProtocolLibrary.getProtocolManager().addPacketListener(new PacketAdapter( + ThirstBar.getInstance(), + ListenerPriority.NORMAL, + PacketType.Play.Server.SET_SLOT, + PacketType.Play.Server.WINDOW_ITEMS, + PacketType.Play.Server.SET_CURSOR_ITEM + ) { + @Override + public void onPacketSending(PacketEvent event) { + try { + PacketContainer packet = event.getPacket(); + if (packet.getType() == PacketType.Play.Server.SET_SLOT) { + ItemStack item = packet.getItemModifier().read(0); + packet.getItemModifier().write(0, applyPacketVisuals(item)); + return; + } + if (packet.getType() == PacketType.Play.Server.WINDOW_ITEMS) { + List items = packet.getItemListModifier().read(0); + if (items == null) return; + List mapped = new ArrayList<>(items.size()); + for (ItemStack i : items) mapped.add(applyPacketVisuals(i)); + packet.getItemListModifier().write(0, mapped); + return; + } + if (packet.getType() == PacketType.Play.Server.SET_CURSOR_ITEM) { + ItemStack cursor = packet.getItemModifier().read(0); + packet.getItemModifier().write(0, applyPacketVisuals(cursor)); + } + } catch (Exception ex) { + ThirstBar.getInstance().getLogger().warning("Protocol item packet rewrite failed: " + ex.getMessage()); + } + } + }); + } catch (Exception e) { + ThirstBar.getInstance().getLogger().warning("Failed to register ProtocolLib listeners: " + e.getMessage()); + } + } + + private EquipmentSlot readHand(PacketContainer packet) { + try { + Object handObj = packet.getEnumModifier(EquipmentSlot.class, 0).read(0); + if (handObj instanceof EquipmentSlot) return (EquipmentSlot) handObj; + } catch (Exception ignore) { + } + return EquipmentSlot.HAND; + } + + private void handleUseItemPacket(@Nonnull Player player) { + PlayerData playerData = ThirstBar.getInstance().getPlayerDataList().addData(player); + if (playerData.isDisableAll() || playerData.isDisable()) return; + ItemStack hand = player.getInventory().getItemInMainHand(); + if (hand == null || hand.getType() == Material.AIR) return; + ItemData itemData = ThirstBar.getInstance().getItemDataList().getData(hand); + if (itemData == null) return; + + if ("on_water".equalsIgnoreCase(itemData.getRightClickType())) { + if (tryFillFromPacketTrace(player, itemData.getRightClickReplace())) return; + } + + if ("on_player".equalsIgnoreCase(itemData.getConsumeType())) { + if (consumeCooldownMap.contains(player.getUniqueId())) return; + if (ConfigData.STOP_DRINKING && playerData.getThirst() >= playerData.getThirstMax()) return; + consumeCooldownMap.add(player.getUniqueId()); + Bukkit.getScheduler().runTaskLater(ThirstBar.getInstance(), () -> consumeCooldownMap.remove(player.getUniqueId()), 40L); + player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_DRINK, 1.0f, 1.0f); + startDrinkingAnimation(player, itemData, hand.clone(), playerData); + } + } + + private boolean tryFillFromPacketTrace(@Nonnull Player player, String replaceName) { + Block traced = getTargetWaterLikeBottle(player); + if (traced == null) return false; + if (traced.getType() == Material.WATER_CAULDRON) decreaseCauldronWaterLevel(traced); + replaceHeldItem(player, replaceName); + playFillWaterSound(player); + return true; + } + + private Block getTargetWaterLikeBottle(@Nonnull Player player) { + try { + Block target = player.getTargetBlockExact(5, FluidCollisionMode.ALWAYS); + return target != null && isFillableWaterSource(target) ? target : null; + } catch (Throwable ignored) { + Block traced = getRayTraceBlock(player, 5.0); + if (traced != null && isFillableWaterSource(traced)) return traced; + return null; + } + } + + private ItemStack applyPacketVisuals(ItemStack source) { + if (source == null || source.getType() == Material.AIR) return source; + ItemData itemData = ThirstBar.getInstance().getItemDataList().getData(source); + if (itemData == null) return source; + ItemStack item = source.clone(); + applyLocalVisualMeta(item, itemData); + return item; + } + + private void applyLocalVisualMeta(@Nonnull ItemStack item, @Nonnull ItemData itemData) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) return; + Integer usage = meta.getPersistentDataContainer().get(usageKey, PersistentDataType.INTEGER); + int currentUsage = usage == null ? 0 : usage; + + if (itemData.isHideAttribute()) { + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES); + try { meta.addItemFlags(ItemFlag.valueOf("HIDE_ATTRIBUTE_MODIFIERS")); } catch (Exception ignore) {} + try { meta.addItemFlags(ItemFlag.valueOf("HIDE_ADDITIONAL_TOOLTIP")); } catch (Exception ignore) {} + try { meta.getClass().getMethod("setHideTooltip", boolean.class).invoke(meta, true); } catch (Exception ignore) {} + try { meta.getClass().getMethod("setAttributeModifiers", com.google.common.collect.Multimap.class).invoke(meta, new Object[] { null }); } catch (Exception ignore) {} + } + applyUsageBarText(meta, itemData, currentUsage); + if (itemData.isShowDurability() && itemData.getConsumeMaxUsage() > 0 && meta instanceof Damageable + && item.getType().getMaxDurability() > 0) { + Damageable damageable = (Damageable) meta; + int maxDurability = item.getType().getMaxDurability(); + int damage = (int) Math.round((Math.min(currentUsage, itemData.getConsumeMaxUsage()) / + (double) itemData.getConsumeMaxUsage()) * (maxDurability - 1)); + damageable.setDamage(Math.max(0, damage)); + } + item.setItemMeta(meta); + } + + private void applyUsageBarText(@Nonnull ItemMeta meta, @Nonnull ItemData itemData, int usage) { + String usageBar = buildUsageBar(itemData, usage); + String usageBarFont = itemData.getUsageBarFont(); + String displayTemplate = itemData.getDisplayNameTemplate(); + if (displayTemplate != null) { + meta.displayName(renderTemplateWithUsageBar(displayTemplate, usageBar, usageBarFont)); + } + List loreTemplate = itemData.getLoreTemplate(); + if (loreTemplate != null && !loreTemplate.isEmpty()) { + List lore = new ArrayList<>(loreTemplate.size()); + for (String line : loreTemplate) { + lore.add(renderTemplateWithUsageBar(line, usageBar, usageBarFont)); + } + meta.lore(lore); + } + } + + private Component renderTemplateWithUsageBar(@Nonnull String template, @Nonnull String usageBar, String usageBarFont) { + String normalizedTemplate = template.replace("\\n", "\n"); + String[] parts = normalizedTemplate.split(java.util.regex.Pattern.quote(""), -1); + Component out = Component.empty(); + Key fontKey = parseFontKey(usageBarFont); + for (int i = 0; i < parts.length; i++) { + if (!parts[i].isEmpty()) { + out = out.append(LEGACY_SERIALIZER.deserialize(parts[i]).decoration(TextDecoration.ITALIC, false)); + } + if (i < parts.length - 1) { + Component usageComponent = LEGACY_SERIALIZER.deserialize(usageBar); + if (fontKey != null) usageComponent = usageComponent.font(fontKey); + usageComponent = usageComponent + .decoration(TextDecoration.ITALIC, false) + .colorIfAbsent(NamedTextColor.WHITE); + out = out.append(usageComponent); + } + } + return out.decoration(TextDecoration.ITALIC, false); + } + + private Key parseFontKey(String font) { + if (font == null || font.trim().isEmpty()) return null; + try { + return Key.key(font.trim()); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private String buildUsageBar(@Nonnull ItemData itemData, int usage) { + if (itemData.getConsumeMaxUsage() <= 0) return ""; + int maxUsage = itemData.getConsumeMaxUsage(); + int consumed = Math.max(0, Math.min(usage, maxUsage)); + int filled = Math.max(0, maxUsage - consumed); + String left = itemData.getUsageBarLeft() == null ? "" : itemData.getUsageBarLeft(); + String fill = itemData.getUsageBarFill() == null ? "" : itemData.getUsageBarFill(); + String empty = itemData.getUsageBarEmpty() == null ? "" : itemData.getUsageBarEmpty(); + String right = itemData.getUsageBarRight() == null ? "" : itemData.getUsageBarRight(); + StringBuilder out = new StringBuilder(left.length() + right.length() + ((fill.length() + empty.length()) * maxUsage)); + out.append(left); + for (int i = 0; i < filled; i++) out.append(fill); + for (int i = 0; i < consumed; i++) out.append(empty); + out.append(right); + return out.toString(); + } + + private boolean tryFillFromWaterSource(@Nonnull PlayerInteractEvent e, @Nonnull Player player, String replaceName, @Nonnull ItemStack currentItem) { + if (replaceName == null || replaceName.trim().isEmpty()) return false; + Block clicked = e.getClickedBlock(); + if (clicked != null && isFillableWaterSource(clicked)) { + if (clicked.getType() == Material.WATER_CAULDRON) decreaseCauldronWaterLevel(clicked); + replaceHeldItem(player, replaceName); + playFillWaterSound(player); + return true; + } + Block traced = getRayTraceBlock(player, 4.5); + if (traced != null && isFillableWaterSource(traced)) { + if (traced.getType() == Material.WATER_CAULDRON) decreaseCauldronWaterLevel(traced); + replaceHeldItem(player, replaceName); + playFillWaterSound(player); + return true; + } + return false; + } + + private Block getRayTraceBlock(@Nonnull Player player, double distance) { + try { + Class fluidCollisionModeClass = Class.forName("org.bukkit.FluidCollisionMode"); + Object always = Enum.valueOf((Class) fluidCollisionModeClass.asSubclass(Enum.class), "ALWAYS"); + Object hit = Player.class.getMethod("rayTraceBlocks", double.class, fluidCollisionModeClass) + .invoke(player, distance, always); + if (hit == null) return null; + Object block = hit.getClass().getMethod("getHitBlock").invoke(hit); + return (block instanceof Block) ? (Block) block : null; + } catch (Throwable ignore) { + return null; + } + } + + private boolean isFillableWaterSource(@Nonnull Block block) { + Material type = block.getType(); + if (type == Material.WATER) { + if (block.getBlockData() instanceof Levelled) { + Levelled levelled = (Levelled) block.getBlockData(); + return levelled.getLevel() == 0; + } + return true; + } + if (type == Material.WATER_CAULDRON) { + if (block.getBlockData() instanceof Levelled) { + return ((Levelled) block.getBlockData()).getLevel() > 0; + } + } + return false; + } + + private void decreaseCauldronWaterLevel(@Nonnull Block block) { + if (!(block.getBlockData() instanceof Levelled)) return; + Levelled data = (Levelled) block.getBlockData(); + int next = data.getLevel() - 1; + if (next <= 0) { + block.setType(Material.CAULDRON); + return; + } + data.setLevel(next); + block.setBlockData(data); + } + + private void transferUsage(@Nonnull ItemStack source, @Nonnull ItemStack target) { + ItemMeta sourceMeta = source.getItemMeta(); + ItemMeta targetMeta = target.getItemMeta(); + if (sourceMeta == null || targetMeta == null) return; + PersistentDataContainer sourcePdc = sourceMeta.getPersistentDataContainer(); + if (sourcePdc.has(usageKey, PersistentDataType.INTEGER)) { + Integer usage = sourcePdc.get(usageKey, PersistentDataType.INTEGER); + if (usage != null) targetMeta.getPersistentDataContainer().set(usageKey, PersistentDataType.INTEGER, usage); + } + if (sourceMeta instanceof Damageable && targetMeta instanceof Damageable) { + ((Damageable) targetMeta).setDamage(((Damageable) sourceMeta).getDamage()); + } + target.setItemMeta(targetMeta); + } + + private void playFillWaterSound(@Nonnull Player player) { + try { + player.playSound(player.getLocation(), Sound.ITEM_BOTTLE_FILL, 1.0f, 1.0f); + player.getWorld().playSound(player.getLocation(), Sound.ITEM_BOTTLE_FILL, 0.7f, 1.0f); + } catch (Throwable ignored) { + player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_SPLASH, 0.9f, 1.0f); + player.getWorld().playSound(player.getLocation(), Sound.ENTITY_GENERIC_SPLASH, 0.7f, 1.0f); + } + } + + private boolean isAnyCustomCookingMaterial(@Nonnull Material material) { + for (ItemData data : ThirstBar.getInstance().getItemDataList().getDataList()) { + if (data.getItemStack() == null) continue; + if (data.getCookType() == null || !data.getCookType().equalsIgnoreCase("cooking")) continue; + if (data.getItemStack().getType() == material) return true; + } + return false; + } } diff --git a/src/main/java/me/orineko/thirstbar/manager/ThirstBarMethod.java b/src/main/java/me/orineko/thirstbar/manager/ThirstBarMethod.java index ca4f9b8..d4595e9 100644 --- a/src/main/java/me/orineko/thirstbar/manager/ThirstBarMethod.java +++ b/src/main/java/me/orineko/thirstbar/manager/ThirstBarMethod.java @@ -47,6 +47,10 @@ public static boolean sendItemToInv(@Nonnull Player player, @Nonnull ItemStack i return true; } + private static final java.util.Set HIDDEN_VISUAL_EFFECT_NAMES = new java.util.HashSet<>( + java.util.Arrays.asList("SLOW", "SLOWNESS", "CONFUSION", "NAUSEA", "WEAKNESS") + ); + public static PotionEffect getPotionEffect(@Nonnull String text) { String[] arr = text.split(":"); if (arr.length == 0) return null; @@ -54,7 +58,18 @@ public static PotionEffect getPotionEffect(@Nonnull String text) { int power = (arr.length > 1) ? (int) MethodDefault.formatNumber(arr[1].trim(), 1) : 1; XPotion.Effect effect = XPotion.parseEffect(effString); if (effect == null) return null; - return new PotionEffect(effect.getEffect().getType(), Integer.MAX_VALUE, power - 1); + + // Hide particles and HUD icon for effects that would visually clutter the screen + boolean hideVisuals = HIDDEN_VISUAL_EFFECT_NAMES.contains(effString.toUpperCase()) + || HIDDEN_VISUAL_EFFECT_NAMES.contains(effect.getEffect().getType().getName().toUpperCase()); + return new PotionEffect( + effect.getEffect().getType(), + Integer.MAX_VALUE, + power - 1, + false, // ambient + !hideVisuals, // particles + !hideVisuals // icon + ); } public static String changeDoubleToInt(double value) { @@ -150,9 +165,9 @@ public static void executeAction(@Nonnull Player player, @Nonnull List t } else { if (titleMainRemain != null || titleSubRemain != null) { player.sendTitle( - titleMainRemain != null ? titleMainRemain : "", - titleSubRemain != null ? titleSubRemain : "", - 10, 20, 10); + titleMainRemain != null ? titleMainRemain : "", + titleSubRemain != null ? titleSubRemain : "", + 10, 20, 10); } } }); @@ -177,4 +192,4 @@ public static boolean checkSightIsWater(@Nonnull Player player){ return false; } -} +} \ No newline at end of file diff --git a/src/main/java/me/orineko/thirstbar/manager/item/ItemData.java b/src/main/java/me/orineko/thirstbar/manager/item/ItemData.java index b710aba..bb3332b 100644 --- a/src/main/java/me/orineko/thirstbar/manager/item/ItemData.java +++ b/src/main/java/me/orineko/thirstbar/manager/item/ItemData.java @@ -20,6 +20,31 @@ public class ItemData { private boolean vanilla; private String mmoitemsId; private String itemsadderId; + private boolean hideAttribute; + private boolean showDurability; + private String rightClickType; + private String rightClickReplace; + private String consumeType; + private int consumeMaxUsage; + private double consumeRestore; + private String consumeWaterType; + private String consumeReplace; + private String drinkingModelMaterial; + private int drinkingModelData; + private String cookType; + private float cookExp; + private int cookTime; + private String cookReplace; + private String craftingType; + private java.util.Map craftingVariable; + private java.util.List craftingRecipe; + private String usageBarLeft; + private String usageBarFill; + private String usageBarEmpty; + private String usageBarRight; + private String usageBarFont; + private String displayNameTemplate; + private java.util.List loreTemplate; private final FileManager file; public ItemData(@Nonnull String name){ @@ -30,6 +55,31 @@ public ItemData(@Nonnull String name){ this.vanilla = false; this.mmoitemsId = null; this.itemsadderId = null; + this.hideAttribute = false; + this.showDurability = false; + this.rightClickType = null; + this.rightClickReplace = null; + this.consumeType = null; + this.consumeMaxUsage = 0; + this.consumeRestore = 0; + this.consumeWaterType = null; + this.consumeReplace = null; + this.drinkingModelMaterial = null; + this.drinkingModelData = 0; + this.cookType = null; + this.cookExp = 0; + this.cookTime = 0; + this.cookReplace = null; + this.craftingType = null; + this.craftingVariable = new java.util.HashMap<>(); + this.craftingRecipe = new java.util.ArrayList<>(); + this.usageBarLeft = ""; + this.usageBarFill = ""; + this.usageBarEmpty = ""; + this.usageBarRight = ""; + this.usageBarFont = "minecraft:default"; + this.displayNameTemplate = null; + this.loreTemplate = new java.util.ArrayList<>(); this.file = ThirstBar.getInstance().getItemsFile(); } diff --git a/src/main/java/me/orineko/thirstbar/manager/item/ItemDataList.java b/src/main/java/me/orineko/thirstbar/manager/item/ItemDataList.java index 2ff82bf..df3dcb2 100644 --- a/src/main/java/me/orineko/thirstbar/manager/item/ItemDataList.java +++ b/src/main/java/me/orineko/thirstbar/manager/item/ItemDataList.java @@ -7,14 +7,21 @@ import me.orineko.thirstbar.api.sql.SqlManager; import me.orineko.thirstbar.manager.file.ConfigData; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.attribute.Attribute; +import org.bukkit.persistence.PersistentDataType; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.*; public class ItemDataList extends MultiIndexDataMap { + private final NamespacedKey customItemIdKey = new NamespacedKey(ThirstBar.getInstance(), "custom_item_id"); @Override protected void setupIndexes() { @@ -75,10 +82,35 @@ public ItemData getData(@Nonnull ItemStack itemStack) { }); if (externalData != null) return externalData; + // Match by persistent custom id first (stable even when durability/damage changes) + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta != null) { + String customId = itemMeta.getPersistentDataContainer().get(customItemIdKey, PersistentDataType.STRING); + if (customId != null && !customId.isEmpty()) { + ItemData byId = getData(customId); + if (byId != null) return byId; + } + } + // Then check custom items (exact match) ItemData itemDataCustom = getData(d -> d.getItemStack() != null && !d.isVanilla() && d.getItemStack().isSimilar(itemStack)); if(itemDataCustom != null) return itemDataCustom; - + + // Fallback for legacy custom items where durability changed but identity should stay + ItemData itemDataIgnoreDamage = getData(d -> { + if (d.isVanilla() || d.getItemStack() == null) return false; + ItemStack base = d.getItemStack().clone(); + ItemStack compare = itemStack.clone(); + ItemMeta baseMeta = base.getItemMeta(); + ItemMeta compareMeta = compare.getItemMeta(); + if (baseMeta instanceof Damageable) ((Damageable) baseMeta).setDamage(0); + if (compareMeta instanceof Damageable) ((Damageable) compareMeta).setDamage(0); + base.setItemMeta(baseMeta); + compare.setItemMeta(compareMeta); + return base.isSimilar(compare); + }); + if (itemDataIgnoreDamage != null) return itemDataIgnoreDamage; + // Then check vanilla items return getData(d -> { ItemStack finalItemStack = itemStack; @@ -109,7 +141,7 @@ public List getDataList() { public void loadData(){ clear(); FileManager file = ThirstBar.getInstance().getItemsFile(); - + // Load vanilla items List vanillaItems = file.getStringList("vanilla-items"); vanillaItems.forEach(v -> { @@ -143,7 +175,7 @@ public void loadData(){ String materialStr = file.getString(path + "material", ""); String mmoitemsId = file.getString(path + "mmoitems-id", ""); String restoreStr = file.getString(path + "restore", "0"); - + double value = 0; double valuePercent = 0; if(restoreStr.endsWith("%")){ @@ -151,36 +183,114 @@ public void loadData(){ } else { value = MethodDefault.formatNumber(restoreStr, 0); } - + ItemData itemData = new ItemData(key); if (valuePercent != 0) itemData.setValuePercent(valuePercent); else itemData.setValue(value); + itemData.setShowDurability(file.getBoolean(path + "show-durability", false)); + itemData.setHideAttribute(file.getBoolean(path + "hide-attribute", false)); + itemData.setRightClickType(file.getString(path + "events.right_click.type")); + itemData.setRightClickReplace(file.getString(path + "events.right_click.replace")); + itemData.setConsumeType(file.getString(path + "events.consume.type")); + itemData.setConsumeMaxUsage(Math.max(0, file.getInt(path + "events.consume.max-usage", 0))); + itemData.setConsumeRestore(file.getDouble(path + "events.consume.restore", 0)); + itemData.setConsumeWaterType(file.getString(path + "events.consume.water-type")); + itemData.setConsumeReplace(file.getString(path + "events.consume.replace")); + itemData.setCookType(file.getString(path + "events.cook.type")); + itemData.setCookExp((float) file.getDouble(path + "events.cook.exp", 0)); + itemData.setCookTime(Math.max(1, file.getInt(path + "events.cook.cooking-time", 1))); + itemData.setCookReplace(file.getString(path + "events.cook.replace")); + itemData.setCraftingType(file.getString(path + "events.crafting.type")); + itemData.setUsageBarLeft(file.getString(path + "usage-bar.left", "")); + itemData.setUsageBarFill(file.getString(path + "usage-bar.fill", "")); + itemData.setUsageBarEmpty(file.getString(path + "usage-bar.empty", "")); + itemData.setUsageBarRight(file.getString(path + "usage-bar.right", "")); + itemData.setUsageBarFont(file.getString(path + "usage-bar.font", "minecraft:default")); + // Load drinking-model visual override + String drinkMat = file.getString(path + "drinking-model.material", ""); + if (!drinkMat.isEmpty()) { + itemData.setDrinkingModelMaterial(drinkMat); + itemData.setDrinkingModelData(file.getInt(path + "drinking-model.custom-model-data", 0)); + } + java.util.Map craftingVar = new java.util.HashMap<>(); + ConfigurationSection variableSec = file.getConfigurationSection(path + "events.crafting.variable"); + if (variableSec != null) { + for (String vKey : variableSec.getKeys(false)) { + if (vKey.length() != 1) continue; + craftingVar.put(vKey.charAt(0), variableSec.getString(vKey, "NONE")); + } + } + itemData.setCraftingVariable(craftingVar); + itemData.setCraftingRecipe(file.getStringList(path + "events.crafting.recipe")); if (!mmoitemsId.isEmpty()) { itemData.setMmoitemsId(mmoitemsId); addData(itemData); return; } - + if (materialStr.startsWith("itemsadder-")) { itemData.setItemsadderId(materialStr.substring(11)); addData(itemData); return; } - + ItemStack item = MethodDefault.getItemAllVersion(materialStr); if(item == null) item = new ItemStack(Material.STONE); - + org.bukkit.inventory.meta.ItemMeta meta = item.getItemMeta(); if (meta != null) { + meta.getPersistentDataContainer().set(customItemIdKey, PersistentDataType.STRING, key); if (file.contains(path + "custom-model-data")) { meta.setCustomModelData(file.getInt(path + "custom-model-data")); } if (file.contains(path + "display_name")) { - meta.setDisplayName(MethodDefault.formatColor(file.getString(path + "display_name"))); + String displayTemplate = MethodDefault.formatColor(file.getString(path + "display_name")); + itemData.setDisplayNameTemplate(displayTemplate); + meta.setDisplayName(displayTemplate); } if (file.contains(path + "lore")) { - meta.setLore(MethodDefault.formatColor(file.getStringList(path + "lore"))); + List loreTemplate = MethodDefault.formatColor(file.getStringList(path + "lore")); + itemData.setLoreTemplate(new ArrayList<>(loreTemplate)); + meta.setLore(loreTemplate); + } + if ("on_player".equalsIgnoreCase(itemData.getConsumeType())) { + applyDrinkConsumable(meta); + } + if (itemData.isHideAttribute()) { + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES); + try { + meta.addItemFlags(ItemFlag.valueOf("HIDE_ATTRIBUTE_MODIFIERS")); + } catch (IllegalArgumentException ignore) {} + try { + meta.addItemFlags(ItemFlag.valueOf("HIDE_ADDITIONAL_TOOLTIP")); + } catch (IllegalArgumentException ignore) {} + try { + meta.getClass().getMethod("setHideTooltip", boolean.class).invoke(meta, true); + } catch (Throwable ignore) {} + try { + meta.removeAttributeModifier(Attribute.ATTACK_DAMAGE); + meta.removeAttributeModifier(Attribute.ATTACK_SPEED); + } catch (Throwable ignore) {} + // 1.20.5+: base weapon attributes (attack damage, attack speed) are stored + // as a Data Component and won't be hidden by ItemFlag alone. + // On 1.21+, setAttributeModifiers(null) completely removes the attribute + // component so nothing appears in the tooltip. + // On older versions the Guava multimap approach is used as fallback. + boolean attributeCleared = false; + try { + // 1.21+ Paper API: passing null removes the attribute data component entirely + meta.getClass().getMethod("setAttributeModifiers", + com.google.common.collect.Multimap.class).invoke(meta, (Object) null); + attributeCleared = true; + } catch (Throwable ignore) {} + if (!attributeCleared) { + try { + com.google.common.collect.Multimap emptyMap = + com.google.common.collect.LinkedHashMultimap.create(); + meta.setAttributeModifiers(emptyMap); + } catch (Throwable ignore) {} + } } item.setItemMeta(meta); } @@ -190,4 +300,18 @@ public void loadData(){ } } + private void applyDrinkConsumable(ItemMeta meta) { + try { + Object food = meta.getClass().getMethod("getFood").invoke(meta); + food.getClass().getMethod("setCanAlwaysEat", boolean.class).invoke(food, true); + food.getClass().getMethod("setNutrition", int.class).invoke(food, 0); + food.getClass().getMethod("setSaturation", float.class).invoke(food, 0.0f); + try { + food.getClass().getMethod("setEatSeconds", float.class).invoke(food, 1.2f); + } catch (NoSuchMethodException ignore) {} + meta.getClass().getMethod("setFood", food.getClass()).invoke(meta, food); + } catch (Throwable ignore) { + } + } + } diff --git a/src/main/resources/items.yml b/src/main/resources/items.yml index 21d73e1..e0518ef 100644 --- a/src/main/resources/items.yml +++ b/src/main/resources/items.yml @@ -12,7 +12,7 @@ # -67 = reduces thirst by 67 points # -99% = reduces 99% of the maximum thirst ######################### - + # Configure vanilla Minecraft items that affect thirst here. # Format: "MATERIAL_NAME:restore_value" vanilla-items: @@ -26,28 +26,100 @@ vanilla-items: # Configure custom items that affect thirst here. # If you do not want to use this feature, set custom-items to []. custom-items: - 'super_juicy_watermelon': - material: MELON_SLICE + 'canteen': + material: WOODEN_SWORD + hide-attribute: true + # Use custom-model-data to apply a custom texture to this item. + # Remove this line if you do not need custom model data. + custom-model-data: 1023 + events: + right_click: + type: on_water + replace: dirty_water_canteen + crafting: + type: crafting + variable: + A: LEATHER + B: STRING + C: CHARCOAL + X: NONE + recipe: + - "X | A | B" + - "A | C | A" + - "X | A | X" + # Hex color codes in the format &#RRGGBB are supported + # in both the display name and lore. + display_name: "&6Canteen" + lore: + - '&7An empty canteen.' + 'dirty_water_canteen': + material: WOODEN_SWORD + drinking-model: + material: POTION + custom-model-data: 1023 + hide-attribute: true + show-durability: true # Show durability bar when the item have been used/consume + # Use custom-model-data to apply a custom texture to this item. + # Remove this line if you do not need custom model data. + custom-model-data: 1023 + events: + right_click: + type: on_water + replace: dirty_water_canteen + consume: + type: on_player + max-usage: 5 + restore: 5 #restore thirst per consume + water-type: RAW_WATER + replace: canteen + cook: + type: cooking + exp: 10.0 + cooking-time: 200 + replace: clean_water_canteen + usage-bar: + font: "customcrops:default" # default is minecraft:default + left: '뀂' # Left cap of the water bar + fill: '뀁뀃' # Segment representing fill water level + empty: '뀁뀄' # Segment representing empty water level + right: '뀁뀅' # Right cap of the water bar + # Hex color codes in the format &#RRGGBB are supported + # in both the display name and lore. + display_name: "&6Dirty Water Canteen\n&r" + lore: + - "&r" # Display the usage bar in the lore + - "&fWhen consumed, it will cause illness." + - "&e&oPlease heat it before drinking." + + 'clean_water_canteen': + material: WOODEN_SWORD + drinking-model: + material: POTION + custom-model-data: 1023 + hide-attribute: true + show-durability: true # Show durability bar when the item have been used/consume # Use custom-model-data to apply a custom texture to this item. # Remove this line if you do not need custom model data. - custom-model-data: 1020 - restore: 50 + custom-model-data: 1023 + events: + right_click: + type: on_water + replace: dirty_water_canteen + consume: + type: on_player + max-usage: 5 + restore: 30 #restore thirst per consume + water-type: CLEAN_WATER + replace: canteen # Hex color codes in the format &#RRGGBB are supported # in both the display name and lore. - display_name: "&6Super Dupper Juicy &#FA3C72Watermelon" + display_name: "&6Clean Water Canteen\n&r" + usage-bar: + font: "customcrops:default" # get from resource pack custom font + left: '뀂' # Left cap of the water bar + fill: '뀁뀃' # Segment representing fill water level + empty: '뀁뀄' # Segment representing empty water level + right: '뀁뀅' # Right cap of the water bar lore: - - '&7A ridiculously juicy watermelon.' - - '&7One bite and your thirst melts away.' - - '' - - '&bRestores a large amount of thirst.' - 'mojito': - # Use itemsadder-[namespace]:[id] to reference an ItemsAdder item. - material: itemsadder-cocktail:mojito - restore: 25% - display_name: "<#C8F1CF>Mojito Cocktail" - 'mmo-items_example': - # Use mmoitems-id: [category]:[id] to reference an MMOItems item. - # The plugin will automatically load the display name, lore, - # and other supported item properties from MMOItems. - mmoitems-id: 'consumable:coke' - restore: 30 + - "&r" # Display the usage bar in the lore + - "&fClean water."