diff --git a/src/main/java/io/github/pylonmc/pylon/base/BaseItems.java b/src/main/java/io/github/pylonmc/pylon/base/BaseItems.java index 739080de0..0608a10e0 100644 --- a/src/main/java/io/github/pylonmc/pylon/base/BaseItems.java +++ b/src/main/java/io/github/pylonmc/pylon/base/BaseItems.java @@ -35,6 +35,7 @@ import io.papermc.paper.datacomponent.item.*; import io.papermc.paper.datacomponent.item.consumable.ConsumeEffect; import io.papermc.paper.datacomponent.item.consumable.ItemUseAnimation; +import io.papermc.paper.registry.keys.SoundEventKeys; import io.papermc.paper.registry.keys.tags.DamageTypeTagKeys; import net.kyori.adventure.key.Key; import org.bukkit.*; @@ -1314,14 +1315,16 @@ private BaseItems() { public static final ItemStack LOUPE = ItemStackBuilder.pylon(Material.CLAY_BALL, BaseKeys.LOUPE) .set(DataComponentTypes.ITEM_MODEL, Material.GLASS_PANE.getKey()) - .set(DataComponentTypes.CONSUMABLE, io.papermc.paper.datacomponent.item.Consumable.consumable() + .set(DataComponentTypes.CONSUMABLE, Consumable.consumable() .animation(ItemUseAnimation.SPYGLASS) .hasConsumeParticles(false) - .consumeSeconds(3) - .sound(Registry.SOUNDS.getKey(Sound.BLOCK_AMETHYST_CLUSTER_HIT)) + .consumeSeconds(Settings.get(BaseKeys.LOUPE).getOrThrow("use-ticks", ConfigAdapter.INT) / 20.0F) + .sound(SoundEventKeys.INTENTIONALLY_EMPTY) + ) + .set(DataComponentTypes.USE_COOLDOWN, UseCooldown.useCooldown( + Settings.get(BaseKeys.LOUPE).getOrThrow("cooldown-ticks", ConfigAdapter.INT)) + .cooldownGroup(BaseKeys.LOUPE) ) - .set(DataComponentTypes.USE_COOLDOWN, UseCooldown.useCooldown(1) - .cooldownGroup(BaseKeys.LOUPE.key())) .build(); static { PylonItem.register(Loupe.class, LOUPE); diff --git a/src/main/java/io/github/pylonmc/pylon/base/command/PylonBaseCommand.java b/src/main/java/io/github/pylonmc/pylon/base/command/PylonBaseCommand.java index 5269b0ef4..842e455a6 100644 --- a/src/main/java/io/github/pylonmc/pylon/base/command/PylonBaseCommand.java +++ b/src/main/java/io/github/pylonmc/pylon/base/command/PylonBaseCommand.java @@ -4,13 +4,17 @@ import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.tree.LiteralCommandNode; import io.github.pylonmc.pylon.base.content.machines.fluid.PortableFluidTank; +import io.github.pylonmc.pylon.base.content.science.Loupe; import io.github.pylonmc.pylon.core.command.RegistryCommandArgument; import io.github.pylonmc.pylon.core.fluid.PylonFluid; import io.github.pylonmc.pylon.core.item.PylonItem; import io.github.pylonmc.pylon.core.registry.PylonRegistry; import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.Commands; +import io.papermc.paper.command.brigadier.argument.ArgumentTypes; +import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver; import lombok.experimental.UtilityClass; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -28,6 +32,13 @@ public class PylonBaseCommand { .executes(PylonBaseCommand::fillFluid) ) ) + .then(Commands.literal("resetloupedata") + .requires(source -> source.getSender().hasPermission("pylonbase.command.reset_loupe")) + .executes(ctx -> resetLoupe(ctx, null)) + .then(Commands.argument("player", ArgumentTypes.player()) + .executes(ctx -> resetLoupe(ctx, ctx.getArgument("player", PlayerSelectorArgumentResolver.class).resolve(ctx.getSource()).getFirst())) + ) + ) .build(); private int fillFluid(CommandContext ctx) { @@ -52,4 +63,20 @@ private int fillFluid(CommandContext ctx) { return Command.SINGLE_SUCCESS; } + + private int resetLoupe(CommandContext ctx, Player target) { + CommandSender sender = ctx.getSource().getSender(); + if (target == null) { + if (!(sender instanceof Player player)) { + sender.sendRichMessage("You must be a player to use this command"); + return Command.SINGLE_SUCCESS; + } + target = player; + } + + target.getPersistentDataContainer().remove(Loupe.CONSUMED_KEY); + sender.sendRichMessage("Reset loupe data for ", + Placeholder.unparsed("target", target.getName())); + return Command.SINGLE_SUCCESS; + } } diff --git a/src/main/java/io/github/pylonmc/pylon/base/content/science/Loupe.java b/src/main/java/io/github/pylonmc/pylon/base/content/science/Loupe.java index e6d7a60c6..adbe7fa7d 100644 --- a/src/main/java/io/github/pylonmc/pylon/base/content/science/Loupe.java +++ b/src/main/java/io/github/pylonmc/pylon/base/content/science/Loupe.java @@ -1,9 +1,12 @@ package io.github.pylonmc.pylon.base.content.science; -import com.google.common.base.Preconditions; +import com.destroystokyo.paper.ParticleBuilder; import io.github.pylonmc.pylon.base.BaseKeys; -import io.github.pylonmc.pylon.base.util.BaseUtils; +import io.github.pylonmc.pylon.base.PylonBase; +import io.github.pylonmc.pylon.base.event.LoupeCompleteScanningEvent; +import io.github.pylonmc.pylon.base.event.LoupeStartScanningEvent; import io.github.pylonmc.pylon.core.block.BlockStorage; +import io.github.pylonmc.pylon.core.config.Config; import io.github.pylonmc.pylon.core.config.ConfigSection; import io.github.pylonmc.pylon.core.config.Settings; import io.github.pylonmc.pylon.core.config.adapter.ConfigAdapter; @@ -14,49 +17,139 @@ import io.github.pylonmc.pylon.core.item.base.PylonInteractor; import io.github.pylonmc.pylon.core.item.research.Research; import io.papermc.paper.datacomponent.DataComponentTypes; -import lombok.Getter; +import io.papermc.paper.registry.RegistryKey; +import io.papermc.paper.registry.TypedKey; +import io.papermc.paper.registry.keys.SoundEventKeys; +import io.papermc.paper.registry.tag.Tag; +import io.papermc.paper.registry.tag.TagKey; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import org.bukkit.Chunk; +import org.bukkit.Effect; import org.bukkit.FluidCollisionMode; import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.Particle; import org.bukkit.Registry; import org.bukkit.block.Block; -import org.bukkit.block.BlockType; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Item; +import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.player.PlayerAttemptPickupItemEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerItemConsumeEvent; import org.bukkit.inventory.ItemRarity; import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.ListPersistentDataType; import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.persistence.PersistentDataType; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.util.RayTraceResult; import org.jetbrains.annotations.NotNull; -import java.util.*; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import static io.github.pylonmc.pylon.base.util.BaseUtils.baseKey; @SuppressWarnings("UnstableApiUsage") -public class Loupe extends PylonItem implements PylonInteractor, PylonConsumable { +public final class Loupe extends PylonItem implements PylonInteractor, PylonConsumable { - private static final NamespacedKey CONSUMED_KEY = BaseUtils.baseKey("consumed"); - private static final PersistentDataType> CONSUMED_TYPE = + public static final NamespacedKey CONSUMED_KEY = baseKey("consumed"); + public static final PersistentDataType> CONSUMED_TYPE = PylonSerializers.MAP.mapTypeFrom( - PylonSerializers.KEYED.keyedTypeFrom(Material.class, Registry.MATERIAL::getOrThrow), + PylonSerializers.NAMESPACED_KEY, PylonSerializers.INTEGER ); - @Getter - private static final Map itemConfigs = new EnumMap<>(ItemRarity.class); + public static final NamespacedKey EXAMINED_KEY = baseKey("examined"); + public static final ListPersistentDataType EXAMINED_TYPE = + PylonSerializers.LIST.listTypeFrom(PylonSerializers.UUID); + public static final PersistentDataType>> CHUNK_EXAMINED_TYPE = + PylonSerializers.MAP.mapTypeFrom( + PylonSerializers.LONG, + EXAMINED_TYPE + ); + + public static final Map ITEM_CONFIGS; + public static final Map ITEM_OVERRIDES; + + public static final EntryConfig ENTITY_DEFAULT_CONFIG; + public static final Map, EntryConfig> ENTITY_CONFIGS; + public static final Map ENTITY_OVERRIDES; + + private static final Map SCANNING = new HashMap<>(); static { + Config loupeConfig = Settings.get(BaseKeys.LOUPE); + + ConfigSection itemOverridesConfig = loupeConfig.getSection("item_overrides"); + Map itemConfigs = new EnumMap<>(ItemRarity.class); + Map itemOverrides = new EnumMap<>(Material.class); for (ItemRarity rarity : ItemRarity.values()) { itemConfigs.put( rarity, - ItemConfig.loadFrom(Settings.get(BaseKeys.LOUPE).getSectionOrThrow(rarity.name().toLowerCase(Locale.ROOT))) + EntryConfig.loadFrom(loupeConfig.getSectionOrThrow("items." + rarity.name().toLowerCase(Locale.ROOT))) ); } + if (itemOverridesConfig != null) { + for (String type : itemOverridesConfig.getKeys()) { + Material material = Registry.MATERIAL.get(Key.key(type)); + if (material == null) { + PylonBase.getInstance().getLogger().warning("Invalid item type '" + type + "' under loupe.yml/item_overrides, skipping!"); + continue; + } + + itemOverrides.put(material, EntryConfig.loadFrom(itemOverridesConfig.getSectionOrThrow(type))); + } + } + + ConfigSection entities = loupeConfig.getSectionOrThrow("entities"); + ConfigSection entityOverridesConfig = loupeConfig.getSection("entity_overrides"); + EntryConfig entityDefaultConfig = EntryConfig.loadFrom(loupeConfig.getSectionOrThrow("entity_default")); + Map, EntryConfig> entityConfigs = new HashMap<>(); + Map entityOverrides = new EnumMap<>(EntityType.class); + for (String tagKey : entities.getKeys()) { + try { + Tag tag = Registry.ENTITY_TYPE.getTag(TagKey.create(RegistryKey.ENTITY_TYPE, tagKey)); + entityConfigs.put(tag, EntryConfig.loadFrom(entities.getSectionOrThrow(tagKey))); + } catch (Exception e) { + PylonBase.getInstance().getLogger().warning("Invalid entity tag '" + tagKey + "' under loupe.yml/entities, skipping!"); + } + } + if (entityOverridesConfig != null) { + for (String type : entityOverridesConfig.getKeys()) { + EntityType entityType = Registry.ENTITY_TYPE.get(Key.key(type)); + if (entityType == null) { + PylonBase.getInstance().getLogger().warning("Invalid entity type '" + type + "' under loupe.yml/entity_overrides, skipping!"); + continue; + } + + entityOverrides.put(entityType, EntryConfig.loadFrom(entityOverridesConfig.getSectionOrThrow(type))); + } + } + + ITEM_CONFIGS = Map.copyOf(itemConfigs); + ITEM_OVERRIDES = Map.copyOf(itemOverrides); + + ENTITY_DEFAULT_CONFIG = entityDefaultConfig; + ENTITY_CONFIGS = Map.copyOf(entityConfigs); + ENTITY_OVERRIDES = Map.copyOf(entityOverrides); } + public final int cooldownTicks = getSettings().getOrThrow("cooldown-ticks", ConfigAdapter.INT); + public Loupe(@NotNull ItemStack stack) { super(stack); } @@ -64,151 +157,274 @@ public Loupe(@NotNull ItemStack stack) { @Override public void onUsedToRightClick(@NotNull PlayerInteractEvent event) { Player player = event.getPlayer(); + if (!event.getAction().isRightClick()) { + return; + } - var items = player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of()); + RayTraceResult scan = player.getWorld().rayTrace(player.getEyeLocation(), player.getEyeLocation().getDirection(), 5, + player.isUnderWater() ? FluidCollisionMode.NEVER : FluidCollisionMode.SOURCE_ONLY, false, 0.25, hit -> hit != player); + if (scan == null) { + return; + } - // get scanned block - Block toScan = player.getTargetBlockExact(5, FluidCollisionMode.SOURCE_ONLY); + RayTraceResult initialScan = SCANNING.get(player.getUniqueId()); + if (initialScan != null && Objects.equals(scan.getHitBlock(), initialScan.getHitBlock()) && Objects.equals(scan.getHitEntity(), initialScan.getHitEntity())) { + return; + } - // nothing found or scanning air - if (toScan == null || toScan.getType().isAir()) { - player.sendMessage(Component.translatable("pylon.pylonbase.message.loupe.nothing")); - event.setCancelled(true); + LoupeStartScanningEvent scanEvent = new LoupeStartScanningEvent(player, scan); + if (!scanEvent.callEvent()) { return; } - // scan for entities first and process the first one found only - var entityItemType = hasValidItem(toScan, items); - if (entityItemType != null) { - ItemStack stack = entityItemType.getItemStack(); + if (scanEvent.isCustomHandled()) { + SCANNING.put(player.getUniqueId(), scan); + } else if (scan.getHitEntity() instanceof Item hit) { + ItemStack stack = hit.getItemStack(); if (PylonItem.fromStack(stack) != null) { - player.sendMessage(Component.translatable("pylon.pylonbase.message.loupe.is_pylon")); - event.setCancelled(true); + player.sendActionBar(message("is_pylon")); + } else if (!stack.getPersistentDataContainer().isEmpty()) { + player.sendActionBar(message("is_other_plugin")); + } else if (!hasUses(player, stack.getType())) { + player.sendActionBar(message("max_uses")); + } else { + player.playSound(Sound.sound(SoundEventKeys.BLOCK_BELL_RESONATE, Sound.Source.PLAYER, 1f, 0.7f)); + player.sendActionBar(message("examining", PylonArgument.of("object", stack.effectiveName()))); + SCANNING.put(player.getUniqueId(), scan); + } + } else if (scan.getHitEntity() instanceof LivingEntity entity) { + if (!player.canSee(entity) || entity.isInvisible() || entity.hasPotionEffect(PotionEffectType.INVISIBILITY)) { return; } - if (!stack.getPersistentDataContainer().isEmpty()) { - player.sendMessage(Component.translatable("pylon.pylonbase.message.loupe.is_other_plugin")); - event.setCancelled(true); - return; + PylonArgument entityArg = PylonArgument.of("object", Component.translatable(entity.getType().translationKey())); + if (entity instanceof Player) { + player.sendActionBar(message("is_player")); + } else if (alreadyExamined(player, entity)) { + player.sendActionBar(message("already_examined", entityArg)); + } else if (!entity.getPersistentDataContainer().isEmpty() && (!entity.getPersistentDataContainer().has(EXAMINED_KEY) || entity.getPersistentDataContainer().getKeys().size() > 1)) { + player.sendActionBar(message("is_other_plugin")); + } else if (!hasUses(player, entity.getType())) { + player.sendActionBar(message("max_uses")); + } else { + player.playSound(Sound.sound(SoundEventKeys.BLOCK_BELL_RESONATE, Sound.Source.PLAYER, 1f, 0.7f)); + player.sendActionBar(message("examining", entityArg)); + SCANNING.put(player.getUniqueId(), scan); + } + } else if (scan.getHitBlock() != null) { + Block hit = scan.getHitBlock(); + Material type = hit.getType(); + if (BlockStorage.get(hit) != null) { + player.sendActionBar(message("is_pylon")); + } else if (!hasUses(player, type)) { + player.sendActionBar(message("max_uses")); + } else if (alreadyExamined(player, hit)) { + player.sendActionBar(message("already_examined", PylonArgument.of("object", Component.translatable(type)))); + } else { + player.playSound(Sound.sound(SoundEventKeys.BLOCK_BELL_RESONATE, Sound.Source.PLAYER, 1f, 0.7f)); + player.sendActionBar(message("examining", PylonArgument.of("object", Component.translatable(type)))); + SCANNING.put(player.getUniqueId(), scan); } + } + } - boolean invalid = processMaterial(stack.getType(), player); - event.setCancelled(invalid); + @Override + public void onConsumed(@NotNull PlayerItemConsumeEvent event) { + event.setCancelled(true); + + Player player = event.getPlayer(); + RayTraceResult initialScan = SCANNING.remove(player.getUniqueId()); + if (initialScan == null) { return; } - // process block aimed at - if (BlockStorage.get(toScan) != null) { - player.sendMessage(Component.translatable("pylon.pylonbase.message.loupe.is_pylon")); - event.setCancelled(true); + RayTraceResult scan = player.getWorld().rayTrace(player.getEyeLocation(), player.getEyeLocation().getDirection(), 5, + player.isUnderWater() ? FluidCollisionMode.NEVER : FluidCollisionMode.SOURCE_ONLY, false, 0.25, hit -> hit != player); + if (scan == null || !Objects.equals(scan.getHitBlock(), initialScan.getHitBlock()) || !Objects.equals(scan.getHitEntity(), initialScan.getHitEntity())) { return; } - Material blockType = toScan.getType(); - event.setCancelled(processMaterial(blockType, player)); - } - - private static boolean processMaterial(Material type, Player player) { - if (!type.isItem()) { - player.sendMessage(Component.translatable("pylon.pylonbase.message.loupe.invalid_block")); - return true; + LoupeCompleteScanningEvent scanEvent = new LoupeCompleteScanningEvent(player, scan); + if (!scanEvent.callEvent()) { + return; } - var items = player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of()); - ItemRarity rarity = type.getDefaultData(DataComponentTypes.RARITY); - int maxUses = itemConfigs.get(rarity).uses; + if (scanEvent.isCustomHandled()) { + //player.setCooldown(getStack(), cooldownTicks); + } else if (scan.getHitEntity() instanceof Item hit) { + ItemStack stack = hit.getItemStack(); + Material type = stack.getType(); + if (PylonItem.fromStack(stack) != null || !stack.getPersistentDataContainer().isEmpty() || !hasUses(player, type)) { + player.sendMessage(message("examine_failed", PylonArgument.of("object", stack.effectiveName()))); + return; + } - if (items.getOrDefault(type, 0) >= maxUses) { - player.sendMessage(Component.translatable("pylon.pylonbase.message.loupe.already_examined")); - return true; - } + PlayerAttemptPickupItemEvent pickupEvent = new PlayerAttemptPickupItemEvent(player, hit, stack.getAmount() - 1); + if (!pickupEvent.callEvent()) { + player.sendMessage(message("examine_failed", PylonArgument.of("object", stack.effectiveName()))); + return; + } + + new ParticleBuilder(Particle.ITEM).data(stack).extra(0.05).count(16).location(hit.getLocation().add(0, hit.getHeight() / 2, 0)).spawn(); + hit.getWorld().playSound(Sound.sound(SoundEventKeys.ENTITY_ITEM_BREAK, Sound.Source.PLAYER, 0.5f, 1f), hit.getX(), hit.getY(), hit.getZ()); + if (stack.getAmount() == 1) { + hit.remove(); + } else { + stack.subtract(); + hit.setItemStack(stack); + } + addEntry(player, stack.effectiveName(), type.getKey(), getEntryConfig(type)); + //player.setCooldown(getStack(), cooldownTicks); + } else if (scan.getHitEntity() instanceof LivingEntity entity) { + PylonArgument entityArg = PylonArgument.of("object", Component.translatable(entity.getType().translationKey())); + if (!player.canSee(entity) || entity.isInvisible() || entity.hasPotionEffect(PotionEffectType.INVISIBILITY) || entity instanceof Player || alreadyExamined(player, entity) || !hasUses(player, entity.getType()) + || (!entity.getPersistentDataContainer().isEmpty() && (!entity.getPersistentDataContainer().has(EXAMINED_KEY) || entity.getPersistentDataContainer().getKeys().size() > 1))) { + player.sendMessage(message("examine_failed", entityArg)); + return; + } + + markAlreadyExamined(player, entity); + addEntry(player, entityArg, entity.getType().getKey(), getEntryConfig(entity.getType())); + //player.setCooldown(getStack(), cooldownTicks); + } else if (scan.getHitBlock() != null) { + Block hit = scan.getHitBlock(); + Material type = hit.getType(); + if (BlockStorage.get(hit) != null || !hasUses(player, type)) { + player.sendMessage(message("examine_failed", PylonArgument.of("object", Component.translatable(type)))); + return; + } + + // Permit unbreakable blocks, just don't try to break them + if (type.getHardness() >= 0) { + BlockBreakEvent breakEvent = new BlockBreakEvent(hit, player); + breakEvent.setDropItems(false); + breakEvent.setExpToDrop(0); + if (!breakEvent.callEvent()) { + player.sendMessage(message("examine_failed", PylonArgument.of("object", Component.translatable(type)))); + return; + } + + hit.getWorld().playEffect(hit.getLocation(), Effect.STEP_SOUND, hit.getBlockData()); + hit.setType(Material.AIR, true); + } else { + // Prevents scanning the same instance of an unbreakable block again + markAlreadyExamined(player, hit); + } - return false; + addEntry(player, Component.translatable(type), type.getKey(), getEntryConfig(type)); + //player.setCooldown(getStack(), cooldownTicks); + } } - @Override - public void onConsumed(@NotNull PlayerItemConsumeEvent event) { - event.setCancelled(true); - Player player = event.getPlayer(); + private Component message(String key, PylonArgument... arguments) { + return Component.translatable("pylon.pylonbase.message.loupe." + key, arguments); + } - var items = new HashMap<>(player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of())); + private static long localChunkPosition(Block block) { + long x = block.getX() & 0xFL; + long z = block.getZ() & 0xFL; + long y = block.getY() & 0xFFFFFFFFL; + return (x << 48) | (y << 16) | z; + } - Block toScan = player.getTargetBlockExact(5, FluidCollisionMode.SOURCE_ONLY); + public static void markAlreadyExamined(Player player, Block block) { + Chunk chunk = block.getChunk(); + PersistentDataContainer pdc = chunk.getPersistentDataContainer(); + Map> examined = pdc.getOrDefault(EXAMINED_KEY, CHUNK_EXAMINED_TYPE, new HashMap<>()); - if (toScan == null || toScan.getType().isAir()) return; + long localPos = localChunkPosition(block); + List examiners = examined.getOrDefault(localPos, new ArrayList<>()); + examiners.add(player.getUniqueId()); + examined.put(localPos, examiners); - // scan for entities first and process the first one found only - org.bukkit.entity.Item entityItem = hasValidItem(toScan, items); - if (entityItem != null) { - ItemStack stack = entityItem.getItemStack(); - if (addPoints(stack.getType(), stack.effectiveName(), player)) return; + pdc.set(EXAMINED_KEY, CHUNK_EXAMINED_TYPE, examined); + } - if (stack.getAmount() == 1) { - entityItem.remove(); - } else { - stack.setAmount(stack.getAmount() - 1); - entityItem.setItemStack(stack); - } + public static void markAlreadyExamined(Player player, LivingEntity entity) { + PersistentDataContainer pdc = entity.getPersistentDataContainer(); + List examiners = pdc.getOrDefault(EXAMINED_KEY, EXAMINED_TYPE, new ArrayList<>()); + examiners.add(player.getUniqueId()); + pdc.set(EXAMINED_KEY, EXAMINED_TYPE, examiners); + } - return; + public static boolean alreadyExamined(Player player, Block block) { + Chunk chunk = block.getChunk(); + PersistentDataContainer pdc = chunk.getPersistentDataContainer(); + if (!pdc.has(EXAMINED_KEY, CHUNK_EXAMINED_TYPE)) { + return false; } - // process block aimed at - Material blockType = toScan.getType(); - BlockType bt = blockType.asBlockType(); - Preconditions.checkNotNull(bt); + Map> examined = pdc.getOrDefault(EXAMINED_KEY, CHUNK_EXAMINED_TYPE, Map.of()); + long localPos = localChunkPosition(block); + return examined.containsKey(localPos) && examined.get(localPos).contains(player.getUniqueId()); + } - if (!new BlockBreakEvent(toScan, player).callEvent()) return; - if (addPoints(blockType, Component.translatable(bt.translationKey()), player)) return; - if (blockType.getHardness() > 0f) { // filter out unbreakable blocks - toScan.setType(Material.AIR); - } + public static boolean alreadyExamined(Player player, LivingEntity entity) { + PersistentDataContainer pdc = entity.getPersistentDataContainer(); + return pdc.has(EXAMINED_KEY, EXAMINED_TYPE) && pdc.getOrDefault(EXAMINED_KEY, EXAMINED_TYPE, List.of()).contains(player.getUniqueId()); } - private static boolean addPoints(Material type, Component name, Player player) { - var items = new HashMap<>(player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of())); - ItemConfig config = itemConfigs.get(type.getDefaultData(DataComponentTypes.RARITY)); + public static boolean hasUses(Player player, Material type) { + var entries = player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of()); + int maxUses = getEntryConfig(type).uses; + return entries.getOrDefault(type.getKey(), 0) < maxUses; + } + + public static boolean hasUses(Player player, EntityType type) { + var entries = player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of()); + int maxUses = getEntryConfig(type).uses; + return entries.getOrDefault(type.getKey(), 0) < maxUses; + } - int currentUses = items.getOrDefault(type, 0); - if (currentUses >= config.uses) return true; // This should never happen + public static void addEntry(Player player, ComponentLike name, NamespacedKey type, EntryConfig config) { + var entries = new HashMap<>(player.getPersistentDataContainer().getOrDefault(CONSUMED_KEY, CONSUMED_TYPE, Map.of())); - items.put(type, currentUses + 1); - player.getPersistentDataContainer().set(CONSUMED_KEY, CONSUMED_TYPE, items); - long points = Research.getResearchPoints(player); - Research.setResearchPoints(player, points + config.points); + entries.put(type, entries.getOrDefault(type, 0) + 1); + player.getPersistentDataContainer().set(CONSUMED_KEY, CONSUMED_TYPE, entries); + + long totalPoints = Research.getResearchPoints(player) + config.points; + Research.setResearchPoints(player, totalPoints); player.sendMessage(Component.translatable( "pylon.pylonbase.message.loupe.examined", - PylonArgument.of("item", name) + PylonArgument.of("object", name) )); player.sendMessage(Component.translatable( "pylon.pylonbase.message.gained_research_points", PylonArgument.of("points", config.points), - PylonArgument.of("total", Research.getResearchPoints(player)) + PylonArgument.of("total", totalPoints) )); - return false; } - private static org.bukkit.entity.Item hasValidItem(Block toScan, Map items) { - Collection entityItems = toScan.getLocation().getNearbyEntitiesByType(org.bukkit.entity.Item.class, 1.2); - for (var item : entityItems) { - ItemStack stack = item.getItemStack(); - ItemRarity rarity = stack.getType().getDefaultData(DataComponentTypes.RARITY); - int maxUses = itemConfigs.get(rarity).uses; + public static EntryConfig getEntryConfig(Material type) { + EntryConfig override = ITEM_OVERRIDES.get(type); + if (override != null) { + return override; + } + + ItemRarity rarity = type.isItem() ? type.getDefaultData(DataComponentTypes.RARITY) : ItemRarity.COMMON; + return ITEM_CONFIGS.get(rarity); + } + + public static EntryConfig getEntryConfig(EntityType type) { + EntryConfig override = ENTITY_OVERRIDES.get(type); + if (override != null) { + return override; + } - // found valid item that hasn't been scanned yet - if (items.getOrDefault(stack.getType(), 0) < maxUses) { - return item; + // TODO: Maybe add a cache for this? + TypedKey typeKey = TypedKey.create(RegistryKey.ENTITY_TYPE, type.getKey()); + for (Map.Entry, EntryConfig> entry : ENTITY_CONFIGS.entrySet()) { + if (entry.getKey().contains(typeKey)) { + return entry.getValue(); } } - - return null; + return ENTITY_DEFAULT_CONFIG; } - public record ItemConfig(int uses, int points) { - public static ItemConfig loadFrom(ConfigSection section) { - return new ItemConfig( + public record EntryConfig(int uses, int points) { + public static EntryConfig loadFrom(ConfigSection section) { + return new EntryConfig( section.getOrThrow("uses", ConfigAdapter.INT), section.getOrThrow("points", ConfigAdapter.INT) ); diff --git a/src/main/java/io/github/pylonmc/pylon/base/event/LoupeCompleteScanningEvent.java b/src/main/java/io/github/pylonmc/pylon/base/event/LoupeCompleteScanningEvent.java new file mode 100644 index 000000000..647027220 --- /dev/null +++ b/src/main/java/io/github/pylonmc/pylon/base/event/LoupeCompleteScanningEvent.java @@ -0,0 +1,56 @@ +package io.github.pylonmc.pylon.base.event; + +import io.github.pylonmc.pylon.base.content.science.Loupe; +import lombok.Getter; +import lombok.Setter; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import org.bukkit.util.RayTraceResult; +import org.jetbrains.annotations.NotNull; + +/** + * Called when a {@link Player} attempts to start scanning something with the {@link Loupe} + * This event marks the completion of a scan and {@link LoupeStartScanningEvent} marks the start of a scan + *
+ * If you'd like to add custom scanning functionality, use {@link LoupeStartScanningEvent#setCustomHandled(boolean)} + * to handle the starting logic (e.g. filtering out anything that shouldn't be scanned, communicating why to players, etc) + * And use {@link LoupeCompleteScanningEvent#setCustomHandled(boolean)} to handle the scan completed logic (e.g. giving + * research points, etc) + */ +public class LoupeCompleteScanningEvent extends PlayerEvent implements Cancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + /** + * The target of the {@link Loupe}'s scan, guaranteed to have either a {@link RayTraceResult#getHitBlock()} + * or {@link RayTraceResult#getHitEntity()} + */ + @Getter private final RayTraceResult scanTarget; + /** + * If the scan logic is being handled by an outside plugin, such as to introduce + * scanning new objects or introducing special cases. + *
+ * When implementing custom handling, the {@link Loupe} will only act as a tracker for beginning and completing + * scans and assuring the target remains the same throughout. No other logic such as player feedback, research + * point rewards, etc, is run, you must do that all yourself. + *
+ * If one plugin is already custom handling, it is recommended not to do it as well. + */ + @Getter @Setter private boolean customHandled = false; + @Getter @Setter private boolean cancelled = false; + + public LoupeCompleteScanningEvent(@NotNull Player player, @NotNull RayTraceResult scanTarget) { + super(player); + this.scanTarget = scanTarget; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/io/github/pylonmc/pylon/base/event/LoupeStartScanningEvent.java b/src/main/java/io/github/pylonmc/pylon/base/event/LoupeStartScanningEvent.java new file mode 100644 index 000000000..f307daa84 --- /dev/null +++ b/src/main/java/io/github/pylonmc/pylon/base/event/LoupeStartScanningEvent.java @@ -0,0 +1,56 @@ +package io.github.pylonmc.pylon.base.event; + +import io.github.pylonmc.pylon.base.content.science.Loupe; +import lombok.Getter; +import lombok.Setter; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import org.bukkit.util.RayTraceResult; +import org.jetbrains.annotations.NotNull; + +/** + * Called when a {@link Player} attempts to start scanning something with the {@link Loupe} + * This event marks the start of a scan and {@link LoupeCompleteScanningEvent} marks the completion of a scan + *
+ * If you'd like to add custom scanning functionality, use {@link LoupeStartScanningEvent#setCustomHandled(boolean)} + * to handle the starting logic (e.g. filtering out anything that shouldn't be scanned, communicating why to players, etc) + * And use {@link LoupeCompleteScanningEvent#setCustomHandled(boolean)} to handle the scan completed logic (e.g. giving + * research points, etc) + */ +public class LoupeStartScanningEvent extends PlayerEvent implements Cancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + /** + * The target of the {@link Loupe}'s scan, guaranteed to have either a {@link RayTraceResult#getHitBlock()} + * or {@link RayTraceResult#getHitEntity()} + */ + @Getter private final RayTraceResult scanTarget; + /** + * If the scan logic is being handled by an outside plugin, such as to introduce + * scanning new objects or introducing special cases. + *
+ * When implementing custom handling, the {@link Loupe} will only act as a tracker for beginning and completing + * scans and assuring the target remains the same throughout. No other logic such as player feedback, research + * point rewards, etc, is run, you must do that all yourself. + *
+ * If one plugin is already custom handling, it is recommended not to do it as well. + */ + @Getter @Setter private boolean customHandled = false; + @Getter @Setter private boolean cancelled = false; + + public LoupeStartScanningEvent(@NotNull Player player, @NotNull RayTraceResult scanTarget) { + super(player); + this.scanTarget = scanTarget; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/resources/lang/en.yml b/src/main/resources/lang/en.yml index e467d019f..8c111fe98 100644 --- a/src/main/resources/lang/en.yml +++ b/src/main/resources/lang/en.yml @@ -2001,12 +2001,14 @@ message: sprinkler_too_close: "You cannot place sprinklers within %radius% blocks of each other" gained_research_points: "+%points% research points (%total% points total)" loupe: - nothing: "You look at your hand. It is not very interesting" - invalid_block: "Try as you may, you cannot glean any information from this block" - already_examined: "You cannot find any more interesting things about this item" is_pylon: "Having built this, you can't think of anything interesting you might find out about it" - examined: "As you examine the %item%, you get a sudden flash of insight" - is_other_plugin: "You smug little knave, use vanilla items not plugin ones" + is_other_plugin: "You smug little knave, examine vanilla objects not plugin ones" + is_player: "Examining other players provides no useful information" + already_examined: "You've already examined this specific %object%, examine a different one" + max_uses: "You cannot find any more interesting things about this object" + examining: "You examine the %object% closely...." + examine_failed: "Failed to examine the %object%. (Do you have permission?)" + examined: "After examining the %object%, you get a sudden flash of insight!" research_pack: message: "As you open the research pack, %happening%" happening: diff --git a/src/main/resources/settings/loupe.yml b/src/main/resources/settings/loupe.yml index 1fb7cab21..df6d0dbf7 100644 --- a/src/main/resources/settings/loupe.yml +++ b/src/main/resources/settings/loupe.yml @@ -1,15 +1,45 @@ -common: +# how long it takes to scan an object +use-ticks: 60 +# after a successful scan, how long should the item be on cooldown +cooldown-ticks: 60 + +# By default, use an item/block's rarity to determine uses and points +# If an item/block has no rarity, default to common +# (Refer to: https://minecraft.wiki/w/Rarity) +items: + common: + uses: 1 + points: 1 + uncommon: + uses: 3 + points: 5 + rare: + uses: 5 + points: 10 + epic: + uses: 10 + points: 25 + +# Per item/block overrides, higher priority than rarity +item_overrides: + minecraft:grass_block: + uses: 1 + points: 1 + +# Default entity settings if no specific settings exist +entity_default: uses: 1 points: 1 -uncommon: - uses: 3 - points: 5 - -rare: - uses: 5 - points: 10 +# Entity settings by tag (refer to: https://minecraft.wiki/w/Entity_type_tag_(Java_Edition)) +# The first matching tag's options will be used (if any) +entities: + minecraft:undead: + uses: 1 + points: 3 -epic: - uses: 10 - points: 25 \ No newline at end of file +# Per entity type overrides, higher priority than tags +entity_overrides: + minecraft:cow: + uses: 1 + points: 1 \ No newline at end of file