diff --git a/blighted-smp/pom.xml b/blighted-smp/pom.xml index 3f363543..082bc8a1 100644 --- a/blighted-smp/pom.xml +++ b/blighted-smp/pom.xml @@ -33,6 +33,16 @@ org.projectlombok lombok + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + @@ -55,6 +65,10 @@ + + org.apache.maven.plugins + maven-surefire-plugin + diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java index edb8cb8f..763b746d 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java @@ -2,7 +2,7 @@ import fr.moussax.blightedSMP.commands.AdminCommand; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import org.bukkit.Location; import org.bukkit.command.Command; import org.bukkit.entity.LivingEntity; @@ -75,7 +75,7 @@ private int butcher(Player player, double radius) { } private void removeEntity(LivingEntity entity) { - BlightedEntity blighted = BlightedEntitiesListener.getBlightedEntity(entity); + BlightedEntity blighted = EntityManager.getBlightedEntity(entity); if (blighted != null) { blighted.cleanup(); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java index e1842270..fe9d5004 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java @@ -20,17 +20,15 @@ protected boolean executeAdmin(Player player, Command command, String label, Str return false; } - BlightedEntity prototype = EntitiesRegistry.get(args[0].toUpperCase()); + BlightedEntity entity = EntitiesRegistry.create(args[0].toUpperCase()); - if (prototype == null) { + if (entity == null) { warn(player, "Unable to find §4" + args[0].toUpperCase() + " §cinto the registry."); return false; } - BlightedEntity entity = prototype.clone(); - if (entity instanceof AncientCreature ancientCreature) { - ancientCreature.setSummoner(player); + ancientCreature.summoner(player); } try { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java index a19f5885..157b3647 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java @@ -51,22 +51,24 @@ public class CorruptedChampion extends BlightedEntity { private int currentPhase = 1; public CorruptedChampion() { - super("Corrupted Champion", 350, MELEE_DAMAGE, EntityType.ZOMBIE); - addAttribute(Attribute.SCALE, 4.0); - addAttribute(Attribute.SPAWN_REINFORCEMENTS, 0.0); - addAttribute(Attribute.KNOCKBACK_RESISTANCE, 0.40); - setBoss(true); - - armor = new ItemStack[]{ - new ItemStack(Material.GOLDEN_BOOTS), - new ItemStack(Material.IRON_LEGGINGS), - new ItemBuilder(Material.GOLDEN_CHESTPLATE).setArmorTrim(TrimMaterial.GOLD, TrimPattern.SNOUT).toItemStack(), - new ItemBuilder(Material.PLAYER_HEAD) + super("CORRUPTED_CHAMPION", "Corrupted Champion", EntityType.ZOMBIE); + attributes(attributes -> attributes + .maxHealth(350) + .attackDamage(MELEE_DAMAGE) + .scale(4.0) + .knockbackResistance(0.40) + .set(Attribute.SPAWN_REINFORCEMENTS, 0.0) + ); + boss(); + equipment(equipment -> equipment + .helmet(new ItemBuilder(Material.PLAYER_HEAD) .setCustomSkullTexture("eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMWFiNTRjMWNlOTQyYzIzMjFkNmRiYjgzMjQ1ZWJmM2ZmZDY5NmJmOWQxZjAyNDY3MzY0NzFmNjdmNzJiYTI1MCJ9fX0=") - .toItemStack() - }; - - itemInMainHand = new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack(); + .toItemStack()) + .chestplate(new ItemBuilder(Material.GOLDEN_CHESTPLATE).setArmorTrim(TrimMaterial.GOLD, TrimPattern.SNOUT).toItemStack()) + .leggings(Material.IRON_LEGGINGS) + .boots(Material.GOLDEN_BOOTS) + .mainHand(new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack()) + ); } private static ItemStack createRocketBoots() { @@ -137,7 +139,7 @@ protected long onPhaseTransition(double healthThreshold) { return 0L; } - setBootsEquipped(armor != null && armor.length > 0 ? armor[0] : new ItemStack(Material.GOLDEN_BOOTS)); + setBoots(new ItemStack(Material.GOLDEN_BOOTS)); if (healthThreshold == 0.66) { currentPhase = 2; @@ -277,7 +279,7 @@ private void executeStomp() { } setPerformingAbility(true); - setBootsEquipped(RED_BOOTS); + setBoots(RED_BOOTS); if (entity instanceof Mob mob) { mob.setAI(false); @@ -330,7 +332,7 @@ public void run() { private void cleanup() { cancel(); - setBootsEquipped(armor != null && armor.length > 0 ? armor[0] : new ItemStack(Material.GOLDEN_BOOTS)); + setBoots(new ItemStack(Material.GOLDEN_BOOTS)); if (entity instanceof Mob mob) { mob.setAI(true); } @@ -724,9 +726,9 @@ public Giant spawnInvertedSword(Location spawnLocation, double scale) { Location targetLocation = spawnLocation.clone(); targetLocation.setPitch(0); targetLocation.setYaw(spawnLocation.getYaw()); - Giant giant = Objects.requireNonNull(spawnLocation.getWorld()).spawn(targetLocation, Giant.class, g -> { - configureSwordGiant(g, scale); - g.setCustomName("Dinnerbone"); + Giant giant = Objects.requireNonNull(spawnLocation.getWorld()).spawn(targetLocation, Giant.class, giantEntity -> { + configureSwordGiant(giantEntity, scale); + giantEntity.setCustomName("Dinnerbone"); } ); return giant; @@ -751,13 +753,6 @@ private void configureSwordGiant(Giant giant, double scale) { Objects.requireNonNull(giant.getEquipment()).setItemInMainHand(new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack()); } - private void setBootsEquipped(ItemStack bootsItem) { - if (entity == null || entity.getEquipment() == null) { - return; - } - entity.getEquipment().setBoots(bootsItem); - } - private boolean tryStartAbility() { if (isPerformingAbility()) { return false; @@ -770,18 +765,6 @@ private void endAbility() { setPerformingAbility(false); } - @Override - public String getEntityId() { - return "CORRUPTED_CHAMPION"; - } - - @Override - public CorruptedChampion clone() { - CorruptedChampion clone = (CorruptedChampion) super.clone(); - clone.activeStabs = new CopyOnWriteArrayList<>(); - return clone; - } - @Override public LivingEntity spawn(Location location) { super.spawn(location); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java index 5cb3e141..0f35fccc 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java @@ -165,29 +165,21 @@ private boolean isSafeEvasionDirection(Vector direction) { @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); } - @Override - public BlightswornArcherArchetype clone() { - BlightswornArcherArchetype clone = (BlightswornArcherArchetype) super.clone(); - clone.isAiming = false; - clone.nextScatterTick = 0; - return clone; - } + } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java index 91e70f1a..04ff7c2d 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java @@ -1,7 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -10,7 +8,6 @@ import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -24,20 +21,26 @@ public BlightswornBogged() { super("BLIGHTSWORN_BOGGED", "Blightsworn Bogged", EntityType.BOGGED); setDamage(6); setDroppedExp(12); - itemInMainHand = new ItemStack(Material.BOW); - setLootTable(new EntityLootTableBuilder() - .addLoot(Material.BONE, 2, 4, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addLoot(Material.TIPPED_ARROW, + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .biomes(Biome.SWAMP, Biome.MANGROVE_SWAMP) + .overworldHostile() + ); + + loot(loot -> loot + .drop(Material.BONE, 2, 4, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .drop(Material.TIPPED_ARROW, builder -> builder.setItemMeta( meta -> ((PotionMeta) meta).setBasePotionType(PotionType.POISON)), 1, 3, 0.4 ) - .addDamagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) - .build()); + .damagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) + ); } @Override @@ -54,9 +57,4 @@ protected void onEnrage(LivingEntity entity) { entity.getWorld().spawnParticle(Particle.SNEEZE, location, 50, 0.5, 1.0, 0.5, 0.05); entity.getWorld().spawnParticle(Particle.DUST, location, 30, 0.5, 1.0, 0.5, 0.0, BLIGHT_DUST); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.SWAMP, Biome.MANGROVE_SWAMP).and(SpawnRules.overworldHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java index bbcceee9..f27024c4 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java @@ -156,31 +156,19 @@ private void spawnBlightResidue() { @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SNOUT) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SNOUT) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); - } - - @Override - public BlightswornBruteArchetype clone() { - BlightswornBruteArchetype clone = (BlightswornBruteArchetype) super.clone(); - clone.blightStacks = 0; - clone.isSurging = false; - clone.isRecovering = false; - clone.isPhaseTwo = false; - return clone; } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java index 1f34809d..34941d79 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.bedrock.utils.ItemBuilder; import lombok.Getter; @@ -40,7 +40,9 @@ protected BlightswornCreature(String entityId, String name, EntityType entityTyp } protected BlightswornCreature(String entityId, String name, EntityType entityType, int maxHealth, double spawnProbability) { - super(entityId, name, maxHealth, entityType, spawnProbability); + super(entityId, name, entityType); + setMaxHealth(maxHealth); + spawning(spawn -> spawn.probability(spawnProbability)); setupDefaultArmor(); } @@ -83,18 +85,18 @@ protected void onRehydrate(LivingEntity existing) { protected abstract void equipEnragedArmor(EntityEquipment equipment); private void setupDefaultArmor() { - armor = new ItemStack[]{ - new ItemStack(Material.AIR), - new ItemStack(Material.AIR), - new ItemBuilder(Material.LEATHER_CHESTPLATE) + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) .setLeatherColor(PHASE_ONE_COLOR) .unbreakable() .toItemStack(), - new ItemBuilder(Material.LEATHER_HELMET) + new ItemBuilder(Material.LEATHER_CHESTPLATE) .setLeatherColor(PHASE_ONE_COLOR) .unbreakable() - .toItemStack() - }; + .toItemStack(), + null, + null + ); } private void evaluateResonance() { @@ -103,7 +105,7 @@ private void evaluateResonance() { boolean hasNearbyAlly = false; for (Entity nearby : entity.getNearbyEntities(12, 12, 12)) { - var blighted = BlightedEntitiesListener.getBlightedEntity(nearby); + var blighted = EntityManager.getBlightedEntity(nearby); if (!(blighted instanceof BlightswornCreature ally) || !ally.isAlive()) continue; hasNearbyAlly = true; @@ -145,12 +147,4 @@ public void setResonating(boolean resonating) { baseSpeedBeforeResonance = -1; } } - - @Override - public BlightswornCreature clone() { - BlightswornCreature clone = (BlightswornCreature) super.clone(); - clone.isResonating = false; - clone.baseSpeedBeforeResonance = -1; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java index d51c9826..96e57b99 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java @@ -1,7 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import fr.moussax.bedrock.utils.ItemBuilder; import org.bukkit.GameMode; import org.bukkit.Location; @@ -36,16 +34,28 @@ public BlightswornDrowned() { super("BLIGHTSWORN_DROWNED", "Blightsworn Drowned", EntityType.DROWNED, MAX_HEALTH); setDamage(6); setDroppedExp(10); - setLootTable(new EntityLootTableBuilder() - .addLoot(Material.ROTTEN_FLESH, 2, 5, 1.0) - .addLoot(Material.COPPER_INGOT, 1, 3, 0.4) - .addLoot(Material.NAUTILUS_SHELL, 0.08, RARE) - .addDamagedItem(Material.TRIDENT, 0.05, 0.80, 0.02, VERY_RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + loot(loot -> loot + .drop(Material.ROTTEN_FLESH, 2, 5, 1.0) + .drop(Material.COPPER_INGOT, 1, 3, 0.4) + .drop(Material.NAUTILUS_SHELL, 0.08, RARE) + .damagedItem(Material.TRIDENT, 0.05, 0.80, 0.02, VERY_RARE) + .gems(5, 0.04, VERY_RARE) ); - itemInMainHand = new ItemBuilder(Material.TRIDENT).unbreakable().toItemStack(); + equipment(eq -> eq.mainHand(new ItemBuilder(Material.TRIDENT).unbreakable().toItemStack())); + + spawning(spawn -> spawn + .biomes( + Biome.RIVER, Biome.FROZEN_RIVER, + Biome.OCEAN, Biome.COLD_OCEAN, Biome.FROZEN_OCEAN, + Biome.LUKEWARM_OCEAN, Biome.WARM_OCEAN, Biome.DEEP_OCEAN, + Biome.DEEP_COLD_OCEAN, Biome.DEEP_FROZEN_OCEAN, + Biome.DEEP_LUKEWARM_OCEAN, Biome.DRIPSTONE_CAVES + ) + .maxBlockLight(0) + .maxLightLevel(7) + .inLiquid() + ); } @Override @@ -149,44 +159,19 @@ private void eruptGeyser(Location center, boolean isPhaseTwo) { @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); - } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.RIVER, Biome.FROZEN_RIVER, - Biome.OCEAN, Biome.COLD_OCEAN, Biome.FROZEN_OCEAN, - Biome.LUKEWARM_OCEAN, Biome.WARM_OCEAN, Biome.DEEP_OCEAN, - Biome.DEEP_COLD_OCEAN, Biome.DEEP_FROZEN_OCEAN, - Biome.DEEP_LUKEWARM_OCEAN, Biome.DRIPSTONE_CAVES - ) - .and(SpawnRules.maxBlockLight(0)) - .and(SpawnRules.maxLightLevel(7)) - .and(SpawnRules.notInLiquid().negate()) + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); } - - @Override - public BlightswornDrowned clone() { - BlightswornDrowned clone = (BlightswornDrowned) super.clone(); - clone.nextRepelTick = 0; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java index 65d56963..3ca8a382 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java @@ -66,42 +66,33 @@ private void triggerBlightWard() { BLIGHT_DUST ); - double wardSpeed = Objects.requireNonNull( - entity.getAttribute(Attribute.MOVEMENT_SPEED)).getBaseValue() * WARD_SPEED_MULTIPLIER; - Objects.requireNonNull(entity.getAttribute(Attribute.MOVEMENT_SPEED)).setBaseValue(wardSpeed); + double baseSpeed = getAttributeValue(Attribute.MOVEMENT_SPEED); + setAttribute(Attribute.MOVEMENT_SPEED, baseSpeed * WARD_SPEED_MULTIPLIER); addCoreDelayedAction(WARD_DURATION_TICKS, () -> { if (!isAlive()) return; isWarded = false; - Objects.requireNonNull(entity.getAttribute(Attribute.MOVEMENT_SPEED)) - .setBaseValue(wardSpeed / WARD_SPEED_MULTIPLIER); + setAttribute(Attribute.MOVEMENT_SPEED, baseSpeed); }); } @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); } - @Override - public BlightswornEliteArchetype clone() { - BlightswornEliteArchetype clone = (BlightswornEliteArchetype) super.clone(); - clone.isWarded = false; - return clone; - } + } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java index 1a977e00..05017890 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java @@ -1,7 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; @@ -18,12 +16,17 @@ public BlightswornHusk() { super("BLIGHTSWORN_HUSK", "Blightsworn Husk", EntityType.HUSK); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() - .addLoot(Material.ROTTEN_FLESH, 2, 5, 1.0) - .addLoot(Material.SAND, 1, 3, 0.3) - .addLoot(Material.IRON_INGOT, 1, 2, 0.1, RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + + spawning(spawn -> spawn + .biomes(Biome.DESERT) + .overworldSurfaceHostile() + ); + + loot(loot -> loot + .drop(Material.ROTTEN_FLESH, 2, 5, 1.0) + .drop(Material.SAND, 1, 3, 0.3) + .drop(Material.IRON_INGOT, 1, 2, 0.1, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -38,9 +41,4 @@ public void onDamageDealt(EntityDamageByEntityEvent event) { protected void applySurgeHitEffects(Player player) { player.addPotionEffect(new PotionEffect(PotionEffectType.HUNGER, 80, 1)); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.DESERT).and(SpawnRules.overworldSurfaceHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java index 82bf55f5..654b3e9f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java @@ -1,7 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -10,7 +8,6 @@ import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -20,15 +17,20 @@ public final class BlightswornParched extends BlightswornArcherArchetype { public BlightswornParched() { super("BLIGHTSWORN_PARCHED", "Blightsworn Parched", EntityType.PARCHED); - itemInMainHand = new ItemStack(Material.BOW); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addDamagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .biomes(Biome.DESERT) + .overworldSurfaceHostile() + ); + + loot(loot -> loot + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .damagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -52,9 +54,4 @@ protected void onEnrage(LivingEntity entity) { entity.getWorld().spawnParticle(Particle.FLAME, location, 30, 0.5, 1.0, 0.5, 0.05); entity.getWorld().spawnParticle(Particle.DUST, location, 30, 0.5, 1.0, 0.5, 0.0, BLIGHT_DUST); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.DESERT).and(SpawnRules.overworldSurfaceHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java index 59923163..5b0236e5 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java @@ -1,7 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -11,7 +9,6 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Piglin; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import org.bukkit.util.Vector; @@ -24,16 +21,20 @@ public final class BlightswornPiglin extends BlightswornEliteArchetype { public BlightswornPiglin() { super("BLIGHTSWORN_PIGLIN", "Blightsworn Piglin", EntityType.PIGLIN); - itemInMainHand = new ItemStack(Material.GOLDEN_SWORD); - setLootTable(new EntityLootTableBuilder() - .addLoot(Material.GOLD_NUGGET, 2, 6, 1.0) - .addLoot(Material.GOLD_INGOT, 1, 3, 0.4) - .addDamagedItem(Material.CROSSBOW, 0.10, 0.80, 0.1, RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + equipment(eq -> eq.mainHand(Material.GOLDEN_SWORD)); + loot(loot -> loot + .drop(Material.GOLD_NUGGET, 2, 6, 1.0) + .drop(Material.GOLD_INGOT, 1, 3, 0.4) + .damagedItem(Material.CROSSBOW, 0.10, 0.80, 0.1, RARE) + .gems(5, 0.04, VERY_RARE) ); setDamage(8); setDroppedExp(16); + + spawning(spawn -> spawn + .biomes(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST) + .netherHostile() + ); } @Override @@ -99,9 +100,4 @@ private void applyHuntingSnare() { ); }); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST).and(SpawnRules.netherHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java index 5034ec0e..cd91233f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java @@ -1,16 +1,12 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; -import org.bukkit.block.Biome; import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -20,15 +16,20 @@ public final class BlightswornSkeleton extends BlightswornArcherArchetype { public BlightswornSkeleton() { super("BLIGHTSWORN_SKELETON", "Blightsworn Skeleton", EntityType.SKELETON); - itemInMainHand = new ItemStack(Material.BOW); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addDamagedItem(Material.BOW, 0.1, 0.8, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .overworld() + .overworldHostile() + ); + + loot(loot -> loot + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .damagedItem(Material.BOW, 0.1, 0.8, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -46,57 +47,4 @@ protected void onEnrage(LivingEntity entity) { entity.getWorld().spawnParticle(Particle.CRIT, location, 30, 0.5, 1.0, 0.5, 0.05); entity.getWorld().spawnParticle(Particle.DUST, location, 30, 0.5, 1.0, 0.5, 0.0, BLIGHT_DUST); } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.PLAINS, - Biome.SUNFLOWER_PLAINS, - Biome.FOREST, - Biome.FLOWER_FOREST, - Biome.BIRCH_FOREST, - Biome.OLD_GROWTH_BIRCH_FOREST, - Biome.DARK_FOREST, - Biome.TAIGA, - Biome.OLD_GROWTH_SPRUCE_TAIGA, - Biome.SNOWY_TAIGA, - Biome.BAMBOO_JUNGLE, - Biome.JUNGLE, - Biome.SPARSE_JUNGLE, - Biome.WINDSWEPT_HILLS, - Biome.WINDSWEPT_FOREST, - Biome.WINDSWEPT_GRAVELLY_HILLS, - Biome.WINDSWEPT_SAVANNA, - Biome.STONY_PEAKS, - Biome.JAGGED_PEAKS, - Biome.FROZEN_PEAKS, - Biome.SNOWY_SLOPES, - Biome.MEADOW, - Biome.GROVE, - Biome.SAVANNA, - Biome.SAVANNA_PLATEAU, - Biome.SWAMP, - Biome.MANGROVE_SWAMP, - Biome.BEACH, - Biome.SNOWY_BEACH, - Biome.STONY_SHORE, - Biome.RIVER, - Biome.FROZEN_RIVER, - Biome.OCEAN, - Biome.COLD_OCEAN, - Biome.FROZEN_OCEAN, - Biome.LUKEWARM_OCEAN, - Biome.WARM_OCEAN, - Biome.DEEP_OCEAN, - Biome.DEEP_COLD_OCEAN, - Biome.DEEP_FROZEN_OCEAN, - Biome.DEEP_LUKEWARM_OCEAN, - Biome.LUSH_CAVES, - Biome.DRIPSTONE_CAVES, - Biome.SOUL_SAND_VALLEY - ) - .and(SpawnRules.overworldHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java index 932b8f5f..027600b9 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java @@ -1,13 +1,10 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.potion.PotionType; @@ -16,22 +13,36 @@ public final class BlightswornStray extends BlightswornArcherArchetype { public BlightswornStray() { super("BLIGHTSWORN_STRAY", "Blightsworn Stray", EntityType.STRAY); - itemInMainHand = new ItemStack(Material.BOW); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .biomes( + Biome.SNOWY_PLAINS, + Biome.ICE_SPIKES, + Biome.FROZEN_OCEAN, + Biome.DEEP_FROZEN_OCEAN, + Biome.FROZEN_RIVER, + Biome.SNOWY_SLOPES, + Biome.JAGGED_PEAKS, + Biome.FROZEN_PEAKS + ) + .overworldSurfaceHostile() + ); + + loot(loot -> loot .maxDrops(4) - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addLoot(Material.TIPPED_ARROW, builder -> builder.setItemMeta( + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .drop(Material.TIPPED_ARROW, builder -> builder.setItemMeta( meta -> ((PotionMeta) meta).setBasePotionType(PotionType.SLOWNESS) ), 1, 3, 0.4 ) - .addGems(5, 0.04, VERY_RARE) - .build() + .gems(5, 0.04, VERY_RARE) ); } @@ -42,21 +53,4 @@ protected void applyArrowEffects(Arrow arrow, boolean isPhaseTwo) { @Override protected void onEnrage(LivingEntity entity) { } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.SNOWY_PLAINS, - Biome.ICE_SPIKES, - Biome.FROZEN_OCEAN, - Biome.DEEP_FROZEN_OCEAN, - Biome.FROZEN_RIVER, - Biome.SNOWY_SLOPES, - Biome.JAGGED_PEAKS, - Biome.FROZEN_PEAKS - ) - .and(SpawnRules.overworldSurfaceHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java index 4a48cade..37500ace 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java @@ -1,7 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -11,7 +9,6 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import org.bukkit.util.Vector; @@ -24,18 +21,22 @@ public final class BlightswornWitherSkeleton extends BlightswornEliteArchetype { public BlightswornWitherSkeleton() { super("BLIGHTSWORN_WITHER_SKELETON", "Blightsworn Wither Skeleton", EntityType.WITHER_SKELETON); - setLootTable(new EntityLootTableBuilder() + loot(loot -> loot .maxDrops(4) - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.COAL, 1, 3, 0.5) - .addLoot(Material.WITHER_SKELETON_SKULL, 0.03, VERY_RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.COAL, 1, 3, 0.5) + .drop(Material.WITHER_SKELETON_SKULL, 0.03, VERY_RARE) + .gems(5, 0.04, VERY_RARE) ); setDamage(8); setDroppedExp(20); - itemInMainHand = new ItemStack(Material.STONE_SWORD); + equipment(eq -> eq.mainHand(Material.STONE_SWORD)); + + spawning(spawn -> spawn + .insideStructure(Structure.FORTRESS) + .maxBlockLight(0) + ); } @Override @@ -92,9 +93,4 @@ private void spawnWitherResidue() { cloud.setParticle(Particle.SMOKE); cloud.addCustomEffect(new PotionEffect(PotionEffectType.WITHER, 60, 0), true); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.insideStructure(Structure.FORTRESS).and(SpawnRules.maxBlockLight(0))); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java index 3b1bbaf6..078e8cbe 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java @@ -1,9 +1,6 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; -import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; @@ -15,70 +12,23 @@ public BlightswornZombie() { super("BLIGHTSWORN_ZOMBIE", "Blightsworn Zombie", EntityType.ZOMBIE); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + + spawning(spawn -> spawn + .overworld() + .overworldHostile() + ); + + loot(loot -> loot .maxDrops(3) - .addLoot(Material.ROTTEN_FLESH, 1, 2, 1.0) - .addLoot(Material.POTATO, 0.025) - .addLoot(Material.CARROT, 0.025) - .addLoot(Material.IRON_INGOT, 0.02, RARE) - .addGems(2, 0.01, VERY_RARE) - .build() + .drop(Material.ROTTEN_FLESH, 1, 2, 1.0) + .drop(Material.POTATO, 0.025) + .drop(Material.CARROT, 0.025) + .drop(Material.IRON_INGOT, 0.02, RARE) + .gems(2, 0.01, VERY_RARE) ); } @Override protected void applySurgeHitEffects(Player player) { } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.PLAINS, - Biome.SUNFLOWER_PLAINS, - Biome.FOREST, - Biome.FLOWER_FOREST, - Biome.BIRCH_FOREST, - Biome.OLD_GROWTH_BIRCH_FOREST, - Biome.DARK_FOREST, - Biome.TAIGA, - Biome.OLD_GROWTH_SPRUCE_TAIGA, - Biome.SNOWY_TAIGA, - Biome.BAMBOO_JUNGLE, - Biome.JUNGLE, - Biome.SPARSE_JUNGLE, - Biome.WINDSWEPT_HILLS, - Biome.WINDSWEPT_FOREST, - Biome.WINDSWEPT_GRAVELLY_HILLS, - Biome.WINDSWEPT_SAVANNA, - Biome.STONY_PEAKS, - Biome.JAGGED_PEAKS, - Biome.FROZEN_PEAKS, - Biome.SNOWY_SLOPES, - Biome.MEADOW, - Biome.GROVE, - Biome.SAVANNA, - Biome.SAVANNA_PLATEAU, - Biome.SWAMP, - Biome.MANGROVE_SWAMP, - Biome.BEACH, - Biome.SNOWY_BEACH, - Biome.STONY_SHORE, - Biome.RIVER, - Biome.FROZEN_RIVER, - Biome.OCEAN, - Biome.COLD_OCEAN, - Biome.FROZEN_OCEAN, - Biome.LUKEWARM_OCEAN, - Biome.WARM_OCEAN, - Biome.DEEP_OCEAN, - Biome.DEEP_COLD_OCEAN, - Biome.DEEP_FROZEN_OCEAN, - Biome.DEEP_LUKEWARM_OCEAN, - Biome.LUSH_CAVES, - Biome.DRIPSTONE_CAVES - ) - .and(SpawnRules.overworldHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java index 30a4e971..1e56d3cc 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -11,7 +10,6 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; -import org.bukkit.inventory.ItemStack; import org.bukkit.util.Vector; import static fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity.*; @@ -22,16 +20,24 @@ public final class BlightswornZombifiedPiglin extends BlightswornEliteArchetype public BlightswornZombifiedPiglin() { super("BLIGHTSWORN_ZOMBIFIED_PIGLIN", "Blightsworn Zombified Piglin", EntityType.ZOMBIFIED_PIGLIN); - itemInMainHand = new ItemStack(Material.GOLDEN_SWORD); setDamage(8); setDroppedExp(16); - setLootTable(new EntityLootTableBuilder() + equipment(eq -> eq.mainHand(Material.GOLDEN_SWORD)); + + spawning(spawn -> spawn + .anyOf( + SpawnRules.biome(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST), + SpawnRules.insideStructure(Structure.FORTRESS) + ) + .netherHostile() + ); + + loot(loot -> loot .maxDrops(3) - .addLoot(Material.ROTTEN_FLESH, 2, 6, 1.0) - .addLoot(Material.GOLD_NUGGET, 2, 6, 1.0) - .addLoot(Material.GOLD_INGOT, 1, 2, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) - .build() + .drop(Material.ROTTEN_FLESH, 2, 6, 1.0) + .drop(Material.GOLD_NUGGET, 2, 6, 1.0) + .drop(Material.GOLD_INGOT, 1, 2, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -93,13 +99,4 @@ private void executePhaseTwoIgnition() { player.setFireTicks(80); // 4 seconds of fire }); } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST) - .or(SpawnRules.insideStructure(Structure.FORTRESS)) - .and(SpawnRules.netherHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java index 7579fd35..0d607b42 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java @@ -6,10 +6,16 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; - public abstract class CelestialCreature extends SpawnableEntity { + + protected CelestialCreature(String entityId, String name, EntityType entityType) { + super(entityId, name, entityType); + } + protected CelestialCreature(String entityId, String name, int maxHealth, EntityType entityType, double probability) { - super(entityId, name, maxHealth, entityType, probability); + super(entityId, name, entityType); + setMaxHealth(maxHealth); + spawning(spawn -> spawn.probability(probability)); } @Override diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java index ecf4d69a..9d45c09a 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java @@ -3,11 +3,12 @@ import fr.moussax.blightedSMP.content.utils.ai.EndermanAI; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; -import org.bukkit.*; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; import org.bukkit.attribute.Attribute; import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; @@ -15,7 +16,6 @@ import org.bukkit.entity.Player; import org.bukkit.event.entity.EntityDamageEvent; -import java.util.Objects; import java.util.Random; import static fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity.*; @@ -26,29 +26,34 @@ public class Endersent extends SpawnableEntity { private int projectileHits = 0; private boolean enraged = false; private boolean isEscaping = false; - private long lastTeleportSmash = 0; private int escapeTicks = 0; public Endersent() { - super("ENDERSENT", "Endersent", 200, EntityType.ENDERMAN, 0.002); + super("ENDERSENT", "Endersent", EntityType.ENDERMAN); + setMaxHealth(200); setDamage(20); setDroppedExp(40); + spawning(spawn -> spawn + .probability(0.002) + .biomes(Biome.END_MIDLANDS) + ); - addAttribute(Attribute.FOLLOW_RANGE, 60); - addAttribute(Attribute.SCALE, 2); - addAttribute(Attribute.KNOCKBACK_RESISTANCE, 1.0); - addAttribute(Attribute.MOVEMENT_SPEED, 0.25); + attributes(attributes -> attributes + .followRange(60) + .scale(2) + .knockbackResistance(1.0) + .movementSpeed(0.25) + ); - setLootTable(new EntityLootTableBuilder() + loot(loot -> loot .maxDrops(2) - .addLoot(Material.ENDER_PEARL, 4, 8, 1.0) - .addLoot(Material.ENDER_EYE, 1, 3, 0.31) - .addLoot("ENCHANTED_ENDER_PEARL", 1, 4, 0.11, RARE) - .addGems(30, 0.03, VERY_RARE) - .build() + .drop(Material.ENDER_PEARL, 4, 8, 1.0) + .drop(Material.ENDER_EYE, 1, 3, 0.31) + .drop("ENCHANTED_ENDER_PEARL", 1, 4, 0.11, RARE) + .gems(30, 0.03, VERY_RARE) ); - setBoss(true); + boss(); } @Override @@ -64,7 +69,7 @@ private void tickCombat() { private void tickSmash() { if (!enraged || isEscaping) return; - if (System.currentTimeMillis() - lastTeleportSmash < SMASH_COOLDOWN) return; + if (!isCooldownReady("smash", SMASH_COOLDOWN)) return; Player target = getNearestPlayer(30); if (target == null) return; @@ -75,20 +80,20 @@ private void tickSmash() { } private void performTeleportSmash(Player target) { - lastTeleportSmash = System.currentTimeMillis(); + triggerCooldown("smash"); - entity.swingMainHand(); + swingMainHand(); Location behind = target.getLocation().add(target.getLocation().getDirection().multiply(-1.5)); behind.setY(target.getLocation().getY()); entity.teleport(behind); - entity.getWorld().playSound(entity.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.0f); + playSound(Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.0f); addCoreDelayedAction(9L, () -> { if (!isAlive()) return; - entity.getWorld().spawnParticle(Particle.EXPLOSION, entity.getLocation(), 1); - entity.getWorld().playSound(entity.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.8f); + spawnParticle(Particle.EXPLOSION, 1); + playSound(Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.8f); damageNearbyPlayers(3.0, 14.0); }); } @@ -125,8 +130,8 @@ private void startDeadlyEscape() { Location location = entity.getLocation(); - Objects.requireNonNull(location.getWorld()).spawnParticle(Particle.EXPLOSION_EMITTER, location, 1); - location.getWorld().playSound(location, Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.5f); + spawnParticle(location, Particle.EXPLOSION_EMITTER, 1); + playSound(location, Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.5f); damageNearbyPlayers(4.0, 18.0); @@ -137,9 +142,9 @@ private void startDeadlyEscape() { int count = 3 + new Random().nextInt(4); for (int i = 0; i < count; i++) { - BlightedEntity prototype = EntitiesRegistry.get("WATCHLING"); - if (prototype == null) continue; - LivingEntity wEntity = prototype.spawn( + BlightedEntity watchling = EntitiesRegistry.create("WATCHLING"); + if (watchling == null) continue; + LivingEntity wEntity = watchling.spawn( location.clone().add((Math.random() - 0.5) * 2, 0, (Math.random() - 0.5) * 2) ); addAttachment(wEntity, AttachmentRole.SUBORDINATE); @@ -162,27 +167,11 @@ private void endDeadlyEscape() { entity.setInvulnerable(false); entity.setAI(true); - entity.getWorld().playSound(reappearLoc, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 0.5f); + playSound(reappearLoc, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 0.5f); } @Override protected void onConfigureAI(LivingEntity spawned) { EndermanAI.init(spawned); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.END_MIDLANDS)); - } - - @Override - public Endersent clone() { - Endersent clone = (Endersent) super.clone(); - clone.projectileHits = 0; - clone.enraged = false; - clone.isEscaping = false; - clone.lastTeleportSmash = 0; - clone.escapeTicks = 0; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java index a4b8a84e..de79c3a4 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.powerful; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.components.impl.ShieldComponent; import fr.moussax.blightedSMP.engine.entities.rituals.AncientCreature; import fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity; @@ -8,26 +7,31 @@ import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.EntityType; +import java.time.Duration; import java.util.List; public class Illusioner extends AncientCreature { public Illusioner() { - super("Ancient Dummy", 20, EntityType.ILLUSIONER, 30); - setDamage(12); + super("ANCIENT_ILLUSIONER", "Ancient Dummy", EntityType.ILLUSIONER); + timeAllowance(Duration.ofSeconds(30)); - this.lootTable = new EntityLootTableBuilder() + attributes(attr -> attr + .attackDamage(12) + ); + + loot(loot -> loot .maxDrops(4) - .addLoot(Material.SPECTRAL_ARROW, 4, 12, 0.6) - .addLoot(Material.GLASS_BOTTLE, 1, 2, 0.4) - .addLoot(Material.TOTEM_OF_UNDYING, 0.02, EntityLootRarity.VERY_RARE) - .addLoot(Material.OMINOUS_BOTTLE, 0.15) - .addGems(12, 0.25) - .addEnchantedBook( + .drop(Material.SPECTRAL_ARROW, 4, 12, 0.6) + .drop(Material.GLASS_BOTTLE, 1, 2, 0.4) + .drop(Material.TOTEM_OF_UNDYING, 0.02, EntityLootRarity.VERY_RARE) + .drop(Material.OMINOUS_BOTTLE, 0.15) + .gems(12, 0.25) + .enchantedBook( List.of(Enchantment.QUICK_CHARGE, Enchantment.PIERCING, Enchantment.POWER), 1, 7, 0.07, EntityLootRarity.RARE ) - .build(); + ); } @Override @@ -38,9 +42,4 @@ protected void onDefineBehavior() { addComponent(new ShieldComponent(120)); } } - - @Override - public String getEntityId() { - return "ANCIENT_ILLUSIONER"; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java index 69af090b..e5aab4c4 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java @@ -1,7 +1,6 @@ package fr.moussax.blightedSMP.content.entities.powerful; import fr.moussax.blightedSMP.content.utils.ai.EndermanAI; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; @@ -11,7 +10,6 @@ import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Mob; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; @@ -23,21 +21,30 @@ public class Watchling extends SpawnableEntity { private static final long TELEPORT_COOLDOWN = 4000; private final Random random = new Random(); - private long lastTeleportTime = 0; public Watchling() { - super("WATCHLING", "§dWatchling", 20, EntityType.ENDERMAN, 0.001); - addAttribute(Attribute.SCALE, 0.7); - addAttribute(Attribute.MOVEMENT_SPEED, 0.35); - addAttribute(Attribute.FOLLOW_RANGE, 50); + super("WATCHLING", "§dWatchling", EntityType.ENDERMAN); + setMaxHealth(20); setDamage(10); setDroppedExp(10); + spawning(spawn -> spawn + .probability(0.001) + .anyOf( + SpawnRules.biome(Biome.END_BARRENS, Biome.END_MIDLANDS), + SpawnRules.insideStructure(Structure.END_CITY) + ) + ); + + attributes(attributes -> attributes + .scale(0.7) + .movementSpeed(0.35) + .followRange(50) + ); - setLootTable(new EntityLootTableBuilder() - .maxDrops(2) - .addLoot(Material.ENDER_PEARL, 1, 2, 1.0) - .addGems(5, 0.03, VERY_RARE) - .build() + loot(loot -> loot + .maxDrops(2) + .drop(Material.ENDER_PEARL, 1, 2, 1.0) + .gems(5, 0.03, VERY_RARE) ); } @@ -47,22 +54,18 @@ protected void onDefineBehavior() { } private void handleCombatLogic() { - if (!isAlive() || !(entity instanceof Mob mob) || !(mob.getTarget() instanceof Player target)) return; + if (!isAlive()) return; + Player target = getTargetPlayer(); + if (target == null) return; double distance = entity.getLocation().distance(target.getLocation()); - if (distance > 6 && distance < 20 && canTeleport()) { + if (distance > 6 && distance < 20 && checkAndTriggerCooldown("teleport", TELEPORT_COOLDOWN)) { teleportToTarget(target); } } - private boolean canTeleport() { - return System.currentTimeMillis() - lastTeleportTime > TELEPORT_COOLDOWN; - } - private void teleportToTarget(Player target) { - lastTeleportTime = System.currentTimeMillis(); - Location location = target.getLocation().add( (random.nextDouble() - 0.5) * 2, 0, @@ -70,7 +73,7 @@ private void teleportToTarget(Player target) { ); entity.teleport(location); - entity.getWorld().playSound(location, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.5f); + playSound(location, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.5f); if (random.nextDouble() < 0.3) { performHeavyAttack(target); @@ -78,14 +81,14 @@ private void teleportToTarget(Player target) { } private void performHeavyAttack(Player target) { - entity.swingMainHand(); - entity.swingOffHand(); + swingMainHand(); + swingOffHand(); addCoreDelayedAction(10L, () -> { if (!isAlive() || target.getLocation().distance(entity.getLocation()) > 3) return; target.damage(getDamage() * 2, entity); - entity.getWorld().playSound(entity.getLocation(), Sound.ENTITY_ZOMBIE_ATTACK_IRON_DOOR, 0.5f, 1.2f); + playSound(Sound.ENTITY_ZOMBIE_ATTACK_IRON_DOOR, 0.5f, 1.2f); if (random.nextBoolean()) { executeEscapeTeleport(); @@ -110,19 +113,4 @@ private void executeEscapeTeleport() { protected void onConfigureAI(LivingEntity spawned) { EndermanAI.init(spawned); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules - .biome(Biome.END_BARRENS, Biome.END_MIDLANDS) - .or(SpawnRules.insideStructure(Structure.END_CITY)) - ); - } - - @Override - public Watchling clone() { - Watchling clone = (Watchling) super.clone(); - clone.lastTeleportTime = 0; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java index e52f6b64..5ee662d1 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java @@ -20,7 +20,10 @@ import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.UUID; public class BonemerangAbility implements AbilityManager { @@ -64,11 +67,6 @@ public boolean triggerAbility(PlayerInteractEvent event) { return true; } - @Override - public int getCooldownSeconds() { - return 2; - } - private void reduceStackInHand(Player player, ItemStack hand) { if (hand.getAmount() <= 1) { player.getInventory().setItemInMainHand(null); @@ -99,6 +97,7 @@ private void launchProjectile(Player player, ItemStack toRestore) { Vector direction = player.getLocation().getDirection().normalize(); String playerName = player.getName(); + Set hitEntities = new HashSet<>(); new BukkitRunnable() { int tick = 0; @@ -123,11 +122,12 @@ public void run() { if (tick == OUTBOUND_TICKS) { returning = true; + hitEntities.clear(); } updateProjectilePosition(projectile, player, direction, returning); updateProjectileRotation(projectile, tick); - damageNearbyEntities(projectile, player); + damageNearbyEntities(projectile, player, hitEntities); spawnParticles(projectile, returning); if (tick >= OUTBOUND_TICKS + RETURN_TICKS) { @@ -172,9 +172,9 @@ private void updateProjectileRotation(ArmorStand projectile, int tick) { )); } - private void damageNearbyEntities(ArmorStand projectile, Player player) { + private void damageNearbyEntities(ArmorStand projectile, Player player, Set hitEntities) { for (Entity entity : projectile.getNearbyEntities(COLLISION_RADIUS, 1, COLLISION_RADIUS)) { - if (entity != player && entity instanceof LivingEntity damageable) { + if (entity != player && entity instanceof LivingEntity damageable && hitEntities.add(entity.getUniqueId())) { damageable.damage(DAMAGE_AMOUNT, player); } } @@ -219,4 +219,4 @@ private boolean replaceNuggetWithOriginal(Inventory inventory, ItemStack origina } return false; } -} \ No newline at end of file +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java index 3c5fcf1a..4e4cdcd7 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java @@ -1,14 +1,27 @@ package fr.moussax.blightedSMP.engine.entities; -import fr.moussax.bedrock.utils.debug.Log; import fr.moussax.blightedSMP.BlightedSMP; import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; import fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachment; -import fr.moussax.blightedSMP.engine.entities.components.AffixRegistry; +import fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager; +import fr.moussax.blightedSMP.engine.entities.boss.BossBarBuilder; +import fr.moussax.blightedSMP.engine.entities.boss.EntityBossBarController; import fr.moussax.blightedSMP.engine.entities.components.EntityComponent; +import fr.moussax.blightedSMP.engine.entities.components.EntityComponentManager; import fr.moussax.blightedSMP.engine.entities.defense.DamageType; +import fr.moussax.blightedSMP.engine.entities.defense.DefensesBuilder; import fr.moussax.blightedSMP.engine.entities.defense.EntityDefenses; import fr.moussax.blightedSMP.engine.entities.defense.EntityImmunity; +import fr.moussax.blightedSMP.engine.entities.equipment.EntityEquipmentBuilder; +import fr.moussax.blightedSMP.engine.entities.equipment.EntityEquipmentHolder; +import fr.moussax.blightedSMP.engine.entities.phases.EntityPhaseManager; +import fr.moussax.blightedSMP.engine.entities.phases.EntityPhasesBuilder; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; +import fr.moussax.blightedSMP.engine.entities.state.EntityAttributeManager; +import fr.moussax.blightedSMP.engine.entities.state.EntityAttributesBuilder; +import fr.moussax.blightedSMP.engine.entities.state.EntityCooldowns; +import fr.moussax.blightedSMP.engine.entities.util.EntityEffects; +import fr.moussax.blightedSMP.engine.entities.util.EntitySpatialQueries; import fr.moussax.blightedSMP.engine.loot.LootContext; import fr.moussax.blightedSMP.engine.loot.LootTable; import fr.moussax.blightedSMP.engine.player.BlightedPlayer; @@ -17,22 +30,22 @@ import org.bukkit.*; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; -import org.bukkit.attribute.AttributeModifier; import org.bukkit.block.Biome; import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; -import org.bukkit.boss.BossBar; import org.bukkit.entity.*; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.inventory.EntityEquipment; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Vector; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; -import java.util.*; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; @@ -45,28 +58,27 @@ *

Instances are stateful and bound to one entity at runtime. API methods must * be called from the server thread unless stated otherwise.

*/ -public abstract class BlightedEntity implements Cloneable { +public abstract class BlightedEntity { public static final NamespacedKey ENTITY_ID_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_entity_id"); - public static final NamespacedKey ATTACHMENT_OWNER_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_owner"); - public static final NamespacedKey ATTACHMENT_ROLE_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_role"); - public static final NamespacedKey ATTACHMENT_OFFSET_X_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_x"); - public static final NamespacedKey ATTACHMENT_OFFSET_Y_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_y"); - public static final NamespacedKey ATTACHMENT_OFFSET_Z_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_z"); - public static final NamespacedKey ATTACHMENT_SYNC_YAW_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_yaw"); - public static final NamespacedKey ATTACHMENT_SYNC_PITCH_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_pitch"); public static final String FAST_PASS_TAG = "blighted_opt"; - private static final double BOSS_BAR_RANGE = 60.0; - - private NavigableMap phaseThresholds = new TreeMap<>(Collections.reverseOrder()); - private LifecycleTaskManager coreTasks = new LifecycleTaskManager(); - private LifecycleTaskManager phaseTasks = new LifecycleTaskManager(); - private Map components = new HashMap<>(); - protected Set attachments = new CopyOnWriteArraySet<>(); - - private long lastDamageTick = -1L; - private UUID lastDamagerUuid = null; + @Getter + protected EntityAttachmentManager attachmentManager = new EntityAttachmentManager(this); + @Getter + protected EntityEquipmentHolder equipmentHolder = new EntityEquipmentHolder(); + @Getter + protected EntityCooldowns cooldowns = new EntityCooldowns(); + @Getter + protected EntityComponentManager componentManager = new EntityComponentManager(); + @Getter + protected EntityPhaseManager phaseManager = new EntityPhaseManager(this, this::onPhaseTransition); + @Getter + protected EntityAttributeManager attributes = new EntityAttributeManager(); + @Nullable + protected EntityBossBarController bossBarController; + @Getter + private EntityDefenses defenses = EntityDefenses.fromClass(getClass()); @Getter protected String entityId; @@ -79,21 +91,17 @@ public abstract class BlightedEntity implements Cloneable { @Getter protected int maxHealth; @Getter - @Setter protected int damage; - @Setter + @Getter protected int defense; @Setter @Getter protected int droppedExp = 0; - protected ItemStack itemInMainHand; - protected ItemStack itemInOffHand; - protected ItemStack[] armor; - - @Setter + @Getter protected LootTable lootTable; + @Getter @Setter protected boolean isBoss = false; @@ -101,55 +109,46 @@ public abstract class BlightedEntity implements Cloneable { @Setter protected boolean isPerformingAbility = false; - protected BossBar bossBar; - protected BarColor bossBarColor = BarColor.RED; - protected BarStyle bossBarStyle = BarStyle.SOLID; - protected Map attributes = new HashMap<>(); - @Getter - private EntityDefenses defenses = EntityDefenses.fromClass(getClass()); - private boolean runtimeInitialized = false; - private boolean componentsInitialized = false; - /** - * Creates an entity with default damage and defense values. - * - * @param name entity display name - * @param maxHealth maximum health - * @param entityType Bukkit entity type - */ - public BlightedEntity(@NonNull String name, int maxHealth, EntityType entityType) { - this(name, maxHealth, 1, 0, entityType); - } + public static final int DEFAULT_MAX_HEALTH = 20; + public static final int DEFAULT_DAMAGE = 2; + public static final int DEFAULT_DEFENSE = 0; /** - * Creates an entity with the given health and damage values. + * Creates an entity definition with basic identity requirements. + * Combat stats and behavior can be configured using fluent setters. * + * @param entityId unique entity identifier * @param name entity display name - * @param maxHealth maximum health - * @param damage base attack damage * @param entityType Bukkit entity type */ - public BlightedEntity(@NonNull String name, int maxHealth, int damage, EntityType entityType) { - this(name, maxHealth, damage, 0, entityType); + public BlightedEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + this.entityId = Objects.requireNonNull(entityId, "entityId cannot be null"); + this.name = Objects.requireNonNull(name, "name cannot be null"); + this.entityType = Objects.requireNonNull(entityType, "entityType cannot be null"); + this.maxHealth = DEFAULT_MAX_HEALTH; + this.damage = DEFAULT_DAMAGE; + this.defense = DEFAULT_DEFENSE; } /** - * Creates an entity with the given base combat stats. + * Obtains or creates the boss bar controller for this entity. * - * @param name entity display name - * @param maxHealth maximum health - * @param damage base attack damage - * @param defense base armor value - * @param entityType Bukkit entity type + * @return the boss bar controller */ - public BlightedEntity(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType) { - this.name = name; - this.maxHealth = maxHealth; - this.damage = damage; - this.defense = defense; - this.entityType = entityType; + @NonNull + public EntityBossBarController getOrCreateBossBar() { + if (bossBarController == null) { + bossBarController = new EntityBossBarController( + BlightedSMP.getInstance(), + () -> entity, + () -> name, + () -> entityType + ); + } + return bossBarController; } /** @@ -168,16 +167,16 @@ public LivingEntity spawn(Location location) { entity.addScoreboardTag(FAST_PASS_TAG); entity.getPersistentDataContainer().set(ENTITY_ID_KEY, PersistentDataType.STRING, getEntityId()); - initializeAttributes(); + attributes.initialize(entity, maxHealth, damage, defense, isBoss); configureEquipment(); onConfigureAI(entity); if (isBoss) { - createBossBar(); + getOrCreateBossBar().create(); } EntityManager.registerEntity(entity, this); - initComponents(); + componentManager.init(entity); initRuntime(); return entity; @@ -194,22 +193,21 @@ public void attachToExisting(LivingEntity existing) { entity.addScoreboardTag(FAST_PASS_TAG); } - rehydrateAttributes(); + attributes.rehydrate(existing, maxHealth, damage, defense, isBoss); onConfigureAI(existing); if (isBoss) { - createBossBar(); + getOrCreateBossBar().create(); } EntityManager.registerEntity(existing, this); - initComponents(); + componentManager.init(existing); onRehydrate(existing); if (!runtimeInitialized) { initRuntime(); } else { - coreTasks.scheduleAll(); - phaseTasks.scheduleAll(); + phaseManager.scheduleAllTasks(); } } @@ -228,17 +226,12 @@ public void kill() { */ public void cleanup() { Location currentLocation = entity != null ? entity.getLocation() : null; - if (currentLocation != null) { - for (EntityComponent component : components.values()) { - component.onDeath(this, currentLocation); - } - } + componentManager.onDeath(this, currentLocation); removeBossBar(); killAllAttachments(); - destroyComponents(); - coreTasks.cancelAll(); - phaseTasks.cancelAll(); + componentManager.destroy(entity); + phaseManager.cancelAllTasks(); EntityManager.unregisterEntity(entity); } @@ -294,8 +287,8 @@ protected void onRehydrate(LivingEntity existing) { * @param healthPercentage threshold in the range {@code 0.0–1.0} * @param onTransition action executed when the phase is triggered */ - protected final void registerPhase(double healthPercentage, Runnable onTransition) { - phaseThresholds.put(healthPercentage, onTransition); + public final void registerPhase(double healthPercentage, Runnable onTransition) { + phaseManager.registerPhase(healthPercentage, onTransition); } /** @@ -314,28 +307,7 @@ protected long onPhaseTransition(double healthThreshold) { * @param currentHealth current health value */ public final void evaluatePhases(double currentHealth) { - if (phaseThresholds.isEmpty()) return; - double healthPercentage = currentHealth / maxHealth; - - while (!phaseThresholds.isEmpty() && healthPercentage <= phaseThresholds.firstKey()) { - Map.Entry entry = phaseThresholds.pollFirstEntry(); - if (entry == null) break; - - phaseTasks.cancelAll(); - phaseTasks = new LifecycleTaskManager(); - entry.getValue().run(); - long transitionDuration = onPhaseTransition(entry.getKey()); - - if (transitionDuration > 0) { - setPerformingAbility(true); - addCoreDelayedAction(transitionDuration, () -> { - setPerformingAbility(false); - phaseTasks.scheduleAll(); - }); - } else { - phaseTasks.scheduleAll(); - } - } + phaseManager.evaluatePhases(currentHealth); } /** @@ -347,7 +319,7 @@ public final void evaluatePhases(double currentHealth) { */ @SuppressWarnings("SameParameterValue") protected final void addCoreAbility(long delayTicks, long periodTicks, Runnable action) { - scheduleAbility(coreTasks, delayTicks, periodTicks, action); + phaseManager.addCoreAbility(delayTicks, periodTicks, action); } /** @@ -358,7 +330,7 @@ protected final void addCoreAbility(long delayTicks, long periodTicks, Runnable * @param action task action */ protected final void addPhaseAbility(long delayTicks, long periodTicks, Runnable action) { - scheduleAbility(phaseTasks, delayTicks, periodTicks, action); + phaseManager.addPhaseAbility(delayTicks, periodTicks, action); } /** @@ -368,7 +340,7 @@ protected final void addPhaseAbility(long delayTicks, long periodTicks, Runnable * @param action task action */ protected final void addCoreDelayedAction(long delayTicks, Runnable action) { - scheduleDelayedAction(coreTasks, delayTicks, action); + phaseManager.addCoreDelayedAction(delayTicks, action); } /** @@ -379,7 +351,7 @@ protected final void addCoreDelayedAction(long delayTicks, Runnable action) { */ @SuppressWarnings("SameParameterValue") protected final void addPhaseDelayedAction(long delayTicks, Runnable action) { - scheduleDelayedAction(phaseTasks, delayTicks, action); + phaseManager.addPhaseDelayedAction(delayTicks, action); } /** @@ -430,6 +402,46 @@ public boolean hasLineOfSight(Entity target) { return entity.hasLineOfSight(target); } + /** + * Returns the current Bukkit target of this entity if it is a {@link Mob}. + * + * @return current target living entity, or {@code null} if none or not a Mob + */ + @Nullable + public LivingEntity getTarget() { + return entity instanceof Mob mob ? mob.getTarget() : null; + } + + /** + * Returns the current Bukkit target of this entity if it is a {@link Player}. + * + * @return current target player, or {@code null} if none or target is not a Player + */ + @Nullable + public Player getTargetPlayer() { + return entity instanceof Mob mob && mob.getTarget() instanceof Player player ? player : null; + } + + /** + * Returns whether this mob currently has an active target. + * + * @return {@code true} if targeting an entity + */ + public boolean hasTarget() { + return getTarget() != null; + } + + /** + * Sets the target of this entity if it is a {@link Mob}. + * + * @param target new target entity, or {@code null} to clear + */ + public void setTarget(@Nullable LivingEntity target) { + if (entity instanceof Mob mob) { + mob.setTarget(target); + } + } + /** * Returns nearby survival-mode players around the entity. * @@ -437,7 +449,7 @@ public boolean hasLineOfSight(Entity target) { * @return nearby players, or an empty list if unavailable */ public List getNearbyPlayers(double radius) { - return getNearbyPlayers(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearbyPlayers(entity != null ? entity.getLocation() : null, radius); } /** @@ -448,18 +460,7 @@ public List getNearbyPlayers(double radius) { * @return nearby players, or an empty list if the center is invalid */ public List getNearbyPlayers(Location center, double radius) { - if (!isAlive() || center == null || center.getWorld() == null) return Collections.emptyList(); - return center - .getWorld() - .getNearbyEntities( - center, - radius, - radius, - radius, - entity -> entity instanceof Player player && player.getGameMode() == GameMode.SURVIVAL) - .stream() - .map(entity -> (Player) entity) - .toList(); + return EntitySpatialQueries.getNearbyPlayers(center, radius); } /** @@ -469,7 +470,7 @@ public List getNearbyPlayers(Location center, double radius) { * @return nearest player, or {@code null} if none exists */ public Player getNearestPlayer(double radius) { - return getNearestPlayer(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearestPlayer(entity != null ? entity.getLocation() : null, radius); } /** @@ -480,10 +481,7 @@ public Player getNearestPlayer(double radius) { * @return nearest player, or {@code null} if none exists */ public Player getNearestPlayer(Location center, double radius) { - if (center == null) return null; - return getNearbyPlayers(center, radius).stream() - .min(Comparator.comparingDouble(player -> player.getLocation().distanceSquared(center))) - .orElse(null); + return EntitySpatialQueries.getNearestPlayer(center, radius); } /** @@ -493,7 +491,7 @@ public Player getNearestPlayer(Location center, double radius) { * @return nearby BlightedMC players */ public List getNearbyBlightedPlayers(double radius) { - return getNearbyBlightedPlayers(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearbyBlightedPlayers(entity != null ? entity.getLocation() : null, radius); } /** @@ -504,10 +502,7 @@ public List getNearbyBlightedPlayers(double radius) { * @return nearby BlightedMC players */ public List getNearbyBlightedPlayers(Location center, double radius) { - return getNearbyPlayers(center, radius).stream() - .map(BlightedPlayer::get) - .filter(Objects::nonNull) - .toList(); + return EntitySpatialQueries.getNearbyBlightedPlayers(center, radius); } /** @@ -517,7 +512,7 @@ public List getNearbyBlightedPlayers(Location center, double rad * @return nearest BlightedMC player, or {@code null} */ public BlightedPlayer getNearestBlightedPlayer(double radius) { - return getNearestBlightedPlayer(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearestBlightedPlayer(entity != null ? entity.getLocation() : null, radius); } /** @@ -528,8 +523,7 @@ public BlightedPlayer getNearestBlightedPlayer(double radius) { * @return nearest BlightedMC player, or {@code null} */ public BlightedPlayer getNearestBlightedPlayer(Location center, double radius) { - Player target = getNearestPlayer(center, radius); - return target != null ? BlightedPlayer.get(target) : null; + return EntitySpatialQueries.getNearestBlightedPlayer(center, radius); } /** @@ -555,10 +549,7 @@ public void meleeAttackNearestPlayer(double radius) { * @param damageAmount damage dealt */ public void damageNearbyPlayers(Location center, double radius, double damageAmount) { - List hitPlayers = getNearbyPlayers(center, radius); - for (Player player : hitPlayers) { - player.damage(damageAmount, entity); - } + EntitySpatialQueries.damageNearbyPlayers(center, radius, damageAmount, entity); } /** @@ -568,7 +559,7 @@ public void damageNearbyPlayers(Location center, double radius, double damageAmo * @param damageAmount damage dealt */ public void damageNearbyPlayers(double radius, double damageAmount) { - damageNearbyPlayers(entity != null ? entity.getLocation() : null, radius, damageAmount); + EntitySpatialQueries.damageNearbyPlayers(entity != null ? entity.getLocation() : null, radius, damageAmount, entity); } /** @@ -587,17 +578,8 @@ public void damageAndKnockbackNearbyPlayers( double knockbackStrength, double verticalKnockback ) { - List hitPlayers = getNearbyPlayers(center, radius); - for (Player player : hitPlayers) { - player.damage(damageAmount, entity); - Vector knockbackVector = player.getLocation().toVector().subtract(center.toVector()).setY(0); - if (knockbackVector.lengthSquared() > 0.001) { - knockbackVector.normalize().multiply(knockbackStrength).setY(verticalKnockback); - } else { - knockbackVector = new Vector(0, verticalKnockback, 0); - } - player.setVelocity(knockbackVector); - } + EntitySpatialQueries.damageAndKnockbackNearbyPlayers( + center, radius, damageAmount, knockbackStrength, verticalKnockback, entity); } /** @@ -607,10 +589,7 @@ public void damageAndKnockbackNearbyPlayers( * @param cooldownTicks duration in ticks to put the shield on cooldown */ public void disableShieldIfBlocking(Player target, int cooldownTicks) { - if (target != null && target.isBlocking()) { - target.setCooldown(Material.SHIELD, cooldownTicks); - target.getWorld().playSound(target.getLocation(), Sound.ITEM_SHIELD_BREAK, 1.0f, 0.8f); - } + EntitySpatialQueries.disableShieldIfBlocking(target, cooldownTicks); } /** @@ -619,17 +598,238 @@ public void disableShieldIfBlocking(Player target, int cooldownTicks) { * @param target target location */ public void faceLocation(Location target) { - if (!isAlive() || target == null) { - return; + EntitySpatialQueries.faceLocation(entity, target); + } + + /** + * Configures this entity's loot table using a fluent builder consumer. + * + * @param consumer action configuring the entity loot table builder + */ + public void loot(@NonNull Consumer consumer) { + EntityLootTableBuilder builder = new EntityLootTableBuilder(); + consumer.accept(builder); + this.lootTable = builder.build(); + } + + /** + * Configures this entity's equipment using a fluent builder consumer. + * + * @param consumer action configuring the entity equipment builder + */ + public void equipment(@NonNull Consumer consumer) { + EntityEquipmentBuilder builder = new EntityEquipmentBuilder(); + consumer.accept(builder); + equipmentHolder.applyBuilder(builder, entity); + } + + /** + * Marks this entity as a boss and configures its boss bar appearance using a fluent consumer. + * + * @param consumer action configuring the boss bar builder + */ + public void boss(@NonNull Consumer consumer) { + this.isBoss = true; + BossBarBuilder builder = new BossBarBuilder(); + consumer.accept(builder); + getOrCreateBossBar().configure(builder); + } + + /** + * Marks this entity as a boss with default boss bar appearance. + */ + public void boss() { + this.isBoss = true; + getOrCreateBossBar(); + } + + /** + * Configures this entity's defenses (immunities and resistances) using a fluent consumer. + * + * @param consumer action configuring the defense builder + */ + public void defenses(@NonNull Consumer consumer) { + consumer.accept(new DefensesBuilder(this)); + } + + /** + * Configures this entity's health-based phase transitions using a fluent consumer. + * + * @param consumer action configuring the phase builder + */ + public void phases(@NonNull Consumer consumer) { + consumer.accept(new EntityPhasesBuilder(this)); + } + + /** + * Configures mob attributes using a fluent consumer builder with typed convenience methods. + * + * @param consumer action configuring the attributes builder + */ + public void attributes(@NonNull Consumer consumer) { + EntityAttributesBuilder builder = new EntityAttributesBuilder(); + consumer.accept(builder); + builder.applyTo(this.attributes, this.entity); + Double configuredMaxHealth = builder.getAttributes().get(Attribute.MAX_HEALTH); + if (configuredMaxHealth != null) { + this.maxHealth = (int) Math.round(configuredMaxHealth); + } + Double configuredDamage = builder.getAttributes().get(Attribute.ATTACK_DAMAGE); + if (configuredDamage != null) { + this.damage = (int) Math.round(configuredDamage); } - Vector direction = target.toVector().subtract(entity.getLocation().toVector()).setY(0); - if (direction.lengthSquared() > 0.001) { - Location location = entity.getLocation(); - location.setYaw((float) Math.toDegrees(-Math.atan2(direction.getX(), direction.getZ()))); - entity.teleport(location); + Double configuredDefense = builder.getAttributes().get(Attribute.ARMOR); + if (configuredDefense != null) { + this.defense = (int) Math.round(configuredDefense); } } + /** + * Plays a sound effect at the entity's current location. + * + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public void playSound(@NonNull Sound sound, float volume, float pitch) { + EntityEffects.playSound(entity, sound, volume, pitch); + } + + /** + * Plays a sound effect at the entity's current location with default volume and pitch (1.0f). + * + * @param sound sound to play + */ + public void playSound(@NonNull Sound sound) { + EntityEffects.playSound(entity, sound, 1.0f, 1.0f); + } + + /** + * Plays a sound effect at a specific world location. + * + * @param location location to play the sound at + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public void playSound(@NonNull Location location, @NonNull Sound sound, float volume, float pitch) { + EntityEffects.playSound(location, sound, volume, pitch); + } + + /** + * Spawns particles at the entity's current location. + * + * @param particle particle type + * @param count number of particles + */ + public void spawnParticle(@NonNull Particle particle, int count) { + EntityEffects.spawnParticle(entity, particle, count); + } + + /** + * Spawns particles at the entity's current location with random positional offsets. + * + * @param particle particle type + * @param count number of particles + * @param offsetX maximum X axis offset + * @param offsetY maximum Y axis offset + * @param offsetZ maximum Z axis offset + */ + public void spawnParticle(@NonNull Particle particle, int count, double offsetX, double offsetY, double offsetZ) { + EntityEffects.spawnParticle(entity, particle, count, offsetX, offsetY, offsetZ); + } + + /** + * Spawns particles at the entity's current location with custom particle data (e.g. DustOptions). + * + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public void spawnParticle(@NonNull Particle particle, int count, @Nullable T data) { + EntityEffects.spawnParticle(entity, particle, count, data); + } + + /** + * Spawns particles at a specific world location. + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + */ + public void spawnParticle(@NonNull Location location, @NonNull Particle particle, int count) { + EntityEffects.spawnParticle(location, particle, count); + } + + /** + * Spawns particles at a specific world location with custom particle data (e.g. DustOptions). + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public void spawnParticle(@NonNull Location location, @NonNull Particle particle, int count, @Nullable T data) { + EntityEffects.spawnParticle(location, particle, count, data); + } + + /** + * Triggers the entity's main-hand swinging animation. + */ + public void swingMainHand() { + EntityEffects.swingMainHand(entity); + } + + /** + * Triggers the entity's off-hand swinging animation. + */ + public void swingOffHand() { + EntityEffects.swingOffHand(entity); + } + + /** + * Checks whether the cooldown for the specified ability key has expired. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis cooldown duration in milliseconds + * @return {@code true} if the cooldown has elapsed and the ability can run + */ + public boolean isCooldownReady(@NonNull String abilityKey, long cooldownMillis) { + return cooldowns.isReady(abilityKey, cooldownMillis); + } + + /** + * Sets or resets the cooldown timestamp for the specified ability to current system time. + * + * @param abilityKey unique identifier for the ability + */ + public void triggerCooldown(@NonNull String abilityKey) { + cooldowns.trigger(abilityKey); + } + + /** + * Checks if the ability cooldown has expired, and if so, immediately records + * the current timestamp and returns {@code true}. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis cooldown duration in milliseconds + * @return {@code true} if the cooldown was ready and was triggered + */ + public boolean checkAndTriggerCooldown(@NonNull String abilityKey, long cooldownMillis) { + return cooldowns.checkAndTrigger(abilityKey, cooldownMillis); + } + + /** + * Resets any recorded cooldown timestamp for the specified ability key. + * + * @param abilityKey unique identifier for the ability + */ + public void resetCooldown(@NonNull String abilityKey) { + cooldowns.reset(abilityKey); + } + /** * Equips or unequips the configured main-hand item. * @@ -639,7 +839,7 @@ public void setMainHandEquipped(boolean equipped) { if (!isAlive() || entity.getEquipment() == null) { return; } - entity.getEquipment().setItemInMainHand(equipped ? itemInMainHand : null); + entity.getEquipment().setItemInMainHand(equipped ? equipmentHolder.getItemInMainHand() : null); } /** @@ -651,7 +851,208 @@ public void setOffHandEquipped(boolean equipped) { if (!isAlive() || entity.getEquipment() == null) { return; } - entity.getEquipment().setItemInOffHand(equipped ? itemInOffHand : null); + entity.getEquipment().setItemInOffHand(equipped ? equipmentHolder.getItemInOffHand() : null); + } + + /** + * Sets the maximum health for this entity. + * If the entity is currently active in the world, dynamically updates + * the live {@link Attribute#MAX_HEALTH} and clamps current health. + * + * @param maxHealth maximum health value + */ + public void setMaxHealth(double maxHealth) { + this.maxHealth = (int) Math.round(maxHealth); + setAttribute(Attribute.MAX_HEALTH, maxHealth); + if (isAlive() && entity != null) { + entity.setHealth(Math.min(entity.getHealth(), maxHealth)); + } + } + + /** + * Sets the live entity's current health, clamped between 0 and maximum health. + * + * @param health target health value + */ + public void setHealth(double health) { + attributes.setHealth(entity, health, this.maxHealth); + } + + /** + * Returns the live entity's current health, or {@code 0.0} if unspawned or dead. + * + * @return current health + */ + public double getHealth() { + return attributes.getHealth(entity); + } + + /** + * Sets the base attack damage value. Updates the live entity if active. + * + * @param damage attack damage + */ + public void setDamage(int damage) { + this.damage = damage; + setAttribute(Attribute.ATTACK_DAMAGE, damage); + } + + /** + * Sets the base defense/armor value. Updates the live entity if active. + * + * @param defense defense value + */ + public void setDefense(int defense) { + this.defense = defense; + if (defense > 0 || isAlive()) { + setAttribute(Attribute.ARMOR, defense); + } + } + + /** + * Sets the entity's helmet. Updates live equipment if the entity is active. + * + * @param helmet helmet item or null to unequip + */ + public void setHelmet(@Nullable ItemStack helmet) { + equipmentHolder.setHelmet(helmet, entity); + } + + /** + * Sets the entity's chestplate. Updates live equipment if the entity is active. + * + * @param chestplate chestplate item or null to unequip + */ + public void setChestplate(@Nullable ItemStack chestplate) { + equipmentHolder.setChestplate(chestplate, entity); + } + + /** + * Sets the entity's leggings. Updates live equipment if the entity is active. + * + * @param leggings leggings item or null to unequip + */ + public void setLeggings(@Nullable ItemStack leggings) { + equipmentHolder.setLeggings(leggings, entity); + } + + /** + * Sets the entity's boots. Updates live equipment if the entity is active. + * + * @param boots boots item or null to unequip + */ + public void setBoots(@Nullable ItemStack boots) { + equipmentHolder.setBoots(boots, entity); + } + + /** + * Sets all four armor slots in natural top-to-bottom order (helmet to boots). + * Any slot may be null to leave it unequipped. + * + * @param helmet helmet item or null + * @param chestplate chestplate item or null + * @param leggings leggings item or null + * @param boots boots item or null + */ + public void setArmor( + @Nullable ItemStack helmet, + @Nullable ItemStack chestplate, + @Nullable ItemStack leggings, + @Nullable ItemStack boots + ) { + equipmentHolder.setArmor(helmet, chestplate, leggings, boots, entity); + } + + /** + * Clears all armor slots. + */ + public void clearArmor() { + equipmentHolder.clearArmor(entity); + } + + /** + * Clears all equipment slots (armor, main-hand, and off-hand). + */ + public void clearEquipment() { + equipmentHolder.clearEquipment(entity); + } + + /** + * Sets the main-hand item. Updates live equipment if the entity is active. + * + * @param item main-hand item or null to unequip + */ + public void setItemInMainHand(@Nullable ItemStack item) { + equipmentHolder.setMainHand(item, entity); + } + + /** + * Sets the off-hand item. Updates live equipment if the entity is active. + * + * @param item off-hand item or null to unequip + */ + public void setItemInOffHand(@Nullable ItemStack item) { + equipmentHolder.setOffHand(item, entity); + } + + /** + * Returns the helmet item from the live entity if active, or from the configured template. + * + * @return current helmet item, or null + */ + @Nullable + public ItemStack getHelmet() { + return equipmentHolder.getHelmet(entity); + } + + /** + * Returns the chestplate item from the live entity if active, or from the configured template. + * + * @return current chestplate item, or null + */ + @Nullable + public ItemStack getChestplate() { + return equipmentHolder.getChestplate(entity); + } + + /** + * Returns the leggings item from the live entity if active, or from the configured template. + * + * @return current leggings item, or null + */ + @Nullable + public ItemStack getLeggings() { + return equipmentHolder.getLeggings(entity); + } + + /** + * Returns the boots item from the live entity if active, or from the configured template. + * + * @return current boots item, or null + */ + @Nullable + public ItemStack getBoots() { + return equipmentHolder.getBoots(entity); + } + + /** + * Returns the main hand item from the live entity if active, or from the configured template. + * + * @return current main hand item, or null + */ + @Nullable + public ItemStack getItemInMainHand() { + return equipmentHolder.getMainHand(entity); + } + + /** + * Returns the off hand item from the live entity if active, or from the configured template. + * + * @return current off hand item, or null + */ + @Nullable + public ItemStack getItemInOffHand() { + return equipmentHolder.getOffHand(entity); } /** @@ -662,18 +1063,7 @@ public void setOffHandEquipped(boolean equipped) { * @return the created ItemDisplay attachment */ public ItemDisplay attachItemDisplay(Vector offset, Consumer configurator) { - if (!isAlive()) { - return null; - } - Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); - Location spawnLoc = entity.getLocation().clone().add(localOffset); - ItemDisplay display = entity.getWorld().spawn(spawnLoc, ItemDisplay.class, itemDisplay -> { - if (configurator != null) { - configurator.accept(itemDisplay); - } - }); - addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); - return display; + return attachmentManager.attachItemDisplay(offset, configurator); } /** @@ -684,18 +1074,7 @@ public ItemDisplay attachItemDisplay(Vector offset, Consumer config * @return the created BlockDisplay attachment */ public BlockDisplay attachBlockDisplay(Vector offset, Consumer configurator) { - if (!isAlive()) { - return null; - } - Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); - Location spawnLoc = entity.getLocation().clone().add(localOffset); - BlockDisplay display = entity.getWorld().spawn(spawnLoc, BlockDisplay.class, d -> { - if (configurator != null) { - configurator.accept(d); - } - }); - addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); - return display; + return attachmentManager.attachBlockDisplay(offset, configurator); } /** @@ -708,20 +1087,7 @@ public BlockDisplay attachBlockDisplay(Vector offset, Consumer con * @return the created Interaction attachment */ public Interaction attachHitbox(Vector offset, float width, float height, Consumer configurator) { - if (!isAlive()) { - return null; - } - Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); - Location spawnLocation = entity.getLocation().clone().add(localOffset); - Interaction interaction = entity.getWorld().spawn(spawnLocation, Interaction.class, i -> { - i.setInteractionWidth(width); - i.setInteractionHeight(height); - if (configurator != null) { - configurator.accept(i); - } - }); - addAttachment(interaction, AttachmentRole.HITBOX, localOffset, true, false); - return interaction; + return attachmentManager.attachHitbox(offset, width, height, configurator); } /** @@ -730,7 +1096,7 @@ public Interaction attachHitbox(Vector offset, float width, float height, Consum * @param attachmentEntity entity to attach */ public void addAttachment(Entity attachmentEntity) { - addAttachment(attachmentEntity, AttachmentRole.SUBORDINATE, new Vector(0, 0, 0), true, false); + attachmentManager.addAttachment(attachmentEntity); } /** @@ -740,7 +1106,7 @@ public void addAttachment(Entity attachmentEntity) { * @param role attachment role */ public void addAttachment(Entity attachmentEntity, AttachmentRole role) { - addAttachment(attachmentEntity, role, new Vector(0, 0, 0), true, false); + attachmentManager.addAttachment(attachmentEntity, role); } /** @@ -751,7 +1117,7 @@ public void addAttachment(Entity attachmentEntity, AttachmentRole role) { * @param offset local 3D offset */ public void addAttachment(Entity attachmentEntity, AttachmentRole role, Vector offset) { - addAttachment(attachmentEntity, role, offset, true, false); + attachmentManager.addAttachment(attachmentEntity, role, offset); } /** @@ -764,111 +1130,21 @@ public void addAttachment(Entity attachmentEntity, AttachmentRole role, Vector o * @param syncPitch whether vertical rotation follows base pitch */ public void addAttachment(Entity attachmentEntity, AttachmentRole role, Vector offset, boolean syncYaw, boolean syncPitch) { - if (attachmentEntity == null) { - return; - } - Vector vector = offset != null ? offset : new Vector(0, 0, 0); - attachments.add(new EntityAttachment(attachmentEntity, role, vector, syncYaw, syncPitch)); - EntityManager.registerAttachment(attachmentEntity, this); - - if (entity != null) { - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OWNER_KEY, PersistentDataType.STRING, entity.getUniqueId().toString()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_ROLE_KEY, PersistentDataType.STRING, role.name()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OFFSET_X_KEY, PersistentDataType.DOUBLE, vector.getX()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OFFSET_Y_KEY, PersistentDataType.DOUBLE, vector.getY()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OFFSET_Z_KEY, PersistentDataType.DOUBLE, vector.getZ()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_SYNC_YAW_KEY, PersistentDataType.BYTE, (byte) (syncYaw ? 1 : 0)); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_SYNC_PITCH_KEY, PersistentDataType.BYTE, (byte) (syncPitch ? 1 : 0)); - } - - if (attachmentEntity instanceof Display display) { - display.setTeleportDuration(1); - display.setInterpolationDuration(1); - } - - if (attachmentEntity instanceof LivingEntity living) { - EntityEquipment equipment = living.getEquipment(); - if (equipment != null) { - zeroEquipmentDropChances(equipment); - } - } - attachmentEntity.addScoreboardTag(FAST_PASS_TAG); + attachmentManager.addAttachment(attachmentEntity, role, offset, syncYaw, syncPitch); } /** * Synchronizes all registered attachments to their relative world position based on base location and facing yaw. */ public void syncAttachments() { - if (!isAlive() || attachments.isEmpty()) { - return; - } - - Location baseLocation = entity.getLocation(); - double radians = Math.toRadians(baseLocation.getYaw()); - double cos = Math.cos(radians); - double sin = Math.sin(radians); - - for (EntityAttachment attachment : attachments) { - Entity attachedEntity = attachment.entity(); - if (attachedEntity == null || !attachedEntity.isValid() || attachedEntity.isDead()) { - attachments.remove(attachment); - if (attachedEntity != null) { - EntityManager.unregisterAttachment(attachedEntity); - } - continue; - } - - if (attachment.role() == AttachmentRole.SUBORDINATE) { - continue; - } - - if (entity != null && entity.getPassengers().contains(attachedEntity)) { - continue; - } - - Vector offset = attachment.localOffset(); - double xPrime = baseLocation.getX() + (offset.getX() * cos - offset.getZ() * sin); - double zPrime = baseLocation.getZ() + (offset.getX() * sin + offset.getZ() * cos); - double yPrime = baseLocation.getY() + offset.getY(); - - Location currentLocation = attachedEntity.getLocation(); - float targetYaw = attachment.syncYaw() ? baseLocation.getYaw() : currentLocation.getYaw(); - float targetPitch = attachment.syncPitch() ? baseLocation.getPitch() : currentLocation.getPitch(); - - if (currentLocation.getWorld() == baseLocation.getWorld() - && Math.abs(currentLocation.getX() - xPrime) < 0.001 - && Math.abs(currentLocation.getY() - yPrime) < 0.001 - && Math.abs(currentLocation.getZ() - zPrime) < 0.001 - && Math.abs(currentLocation.getYaw() - targetYaw) < 0.1f - && Math.abs(currentLocation.getPitch() - targetPitch) < 0.1f) { - continue; - } - - Location targetLocation = new Location(baseLocation.getWorld(), xPrime, yPrime, zPrime, targetYaw, targetPitch); - - attachedEntity.teleport(targetLocation); - } + attachmentManager.syncAttachments(); } /** * Removes and destroys all attached entities. */ public void killAllAttachments() { - if (attachments.isEmpty()) { - return; - } - - for (EntityAttachment attachment : attachments) { - Entity attachmentEntity = attachment.entity(); - - if (attachmentEntity == null) { - continue; - } - - EntityManager.unregisterAttachment(attachmentEntity); - attachmentEntity.remove(); - } - attachments.clear(); + attachmentManager.killAllAttachments(); } /** @@ -877,21 +1153,7 @@ public void killAllAttachments() { * @param role attachment role to remove */ public void killAttachments(AttachmentRole role) { - if (attachments.isEmpty() || role == null) { - return; - } - - attachments.removeIf(attachment -> { - if (attachment.role() != role) { - return false; - } - Entity attachmentEntity = attachment.entity(); - if (attachmentEntity != null) { - EntityManager.unregisterAttachment(attachmentEntity); - attachmentEntity.remove(); - } - return true; - }); + attachmentManager.killAttachments(role); } /** @@ -900,12 +1162,7 @@ public void killAttachments(AttachmentRole role) { * @return {@code true} if a living body attachment exists */ public boolean hasLivingBodyAttachment() { - for (EntityAttachment attachment : attachments) { - if (attachment.entity() instanceof LivingEntity living && !living.isDead()) { - return true; - } - } - return false; + return attachmentManager.hasLivingBodyAttachment(); } /** @@ -914,15 +1171,7 @@ public boolean hasLivingBodyAttachment() { * @return {@code true} if a non-dead subordinate attachment exists */ public boolean hasSubordinateAttachments() { - for (EntityAttachment attachment : attachments) { - if (attachment.role() == AttachmentRole.SUBORDINATE - && attachment.entity() != null - && attachment.entity().isValid() - && !attachment.entity().isDead()) { - return true; - } - } - return false; + return attachmentManager.hasSubordinateAttachments(); } /** @@ -932,20 +1181,7 @@ public boolean hasSubordinateAttachments() { * @return {@code true} if damage was already processed this tick by the same attacker */ public boolean shouldBlockSameTickDamage(Entity damager) { - if (damager == null) { - return false; - } - - long currentTick = entity != null ? entity.getWorld().getGameTime() : System.currentTimeMillis(); - UUID damagerUuid = damager.getUniqueId(); - - if (currentTick == lastDamageTick && Objects.equals(damagerUuid, lastDamagerUuid)) { - return true; - } - - lastDamageTick = currentTick; - lastDamagerUuid = damagerUuid; - return false; + return attachmentManager.shouldBlockSameTickDamage(damager); } /** @@ -956,10 +1192,7 @@ public boolean shouldBlockSameTickDamage(Entity damager) { * @param component component to register */ public void addComponent(EntityComponent component) { - components.put(component.getId(), component); - if (componentsInitialized && entity != null) { - component.onInit(entity); - } + componentManager.addComponent(component, entity); } /** @@ -968,7 +1201,7 @@ public void addComponent(EntityComponent component) { * @return active entity attachments */ public Set getAttachments() { - return Collections.unmodifiableSet(attachments); + return attachmentManager.getAttachments(); } /** @@ -978,9 +1211,8 @@ public Set getAttachments() { * @param expected component type * @return component, or {@code null} if not registered */ - @SuppressWarnings("unchecked") public T getComponent(String id) { - return (T) components.get(id); + return componentManager.getComponent(id); } /** @@ -991,15 +1223,7 @@ public T getComponent(String id) { * @return matching component instance, or {@code null} if not registered */ public T getComponent(Class componentClass) { - if (componentClass == null) { - return null; - } - for (EntityComponent component : components.values()) { - if (componentClass.isInstance(component)) { - return componentClass.cast(component); - } - } - return null; + return componentManager.getComponent(componentClass); } /** @@ -1008,14 +1232,14 @@ public T getComponent(Class componentClass) { * @return registered components */ public Collection getComponents() { - return new ArrayList<>(components.values()); + return componentManager.getComponents(); } /** * Updates the boss bar progress to match the entity's health. */ public void updateBossBar() { - if (bossBar == null) { + if (bossBarController == null || !bossBarController.isActive()) { return; } if (!isAlive()) { @@ -1027,7 +1251,7 @@ public void updateBossBar() { ? maxHealthAttribute.getValue() : Math.max(1, this.maxHealth); double progress = entity.getHealth() / maxHealth; - bossBar.setProgress(Math.clamp(progress, 0.0, 1.0)); + bossBarController.updateProgress(progress); } /** @@ -1037,23 +1261,16 @@ public void updateBossBar() { * @param style bar style */ public void setBossBarAppearance(BarColor color, BarStyle style) { - this.bossBarColor = color; - this.bossBarStyle = style; - if (bossBar != null) { - bossBar.setColor(color); - bossBar.setStyle(style); - } + getOrCreateBossBar().setAppearance(color, style); } /** * Removes the boss bar and all of its viewers. */ public void removeBossBar() { - if (bossBar == null) { - return; + if (bossBarController != null) { + bossBarController.remove(); } - bossBar.removeAll(); - bossBar = null; } /** @@ -1074,13 +1291,25 @@ public void dropLoot(Location location, BlightedPlayer player) { } /** - * Sets an additional Bukkit attribute value. + * Sets an attribute base value. If the entity is currently active, + * updates the live Bukkit AttributeInstance immediately. * - * @param attribute attribute to configure + * @param attribute target attribute * @param value base attribute value */ - public void addAttribute(Attribute attribute, double value) { - attributes.put(attribute, value); + public void setAttribute(Attribute attribute, double value) { + attributes.setAttribute(attribute, value, entity); + } + + /** + * Returns the effective base value for an attribute, querying the live + * entity if active, or the configured attribute map if unspawned. + * + * @param attribute target attribute + * @return effective base value, or 0.0 if not configured + */ + public double getAttributeValue(Attribute attribute) { + return attributes.getAttributeValue(attribute, entity); } /** @@ -1105,19 +1334,6 @@ public double getResistancePercent(LivingEntity target, EntityDamageEvent event) return defenses.getResistancePercent(target, event); } - private void initComponents() { - if (componentsInitialized) { - return; - } - componentsInitialized = true; - components.values().forEach(component -> component.onInit(entity)); - } - - private void destroyComponents() { - components.values().forEach(component -> component.onDestroy(entity)); - componentsInitialized = false; - } - /** * Initializes runtime systems (behavior, tasks, phases). Executed once per instance. */ @@ -1126,92 +1342,17 @@ private void initRuntime() { return; } onDefineBehavior(); - if (bossBar != null) startBossBarTask(); addCoreAbility(1L, 1L, this::syncAttachments); - addCoreAbility(5L, 5L, () -> { - for (EntityComponent component : components.values()) { - component.onTick(this); - } - }); + addCoreAbility(5L, 5L, () -> componentManager.tick(this)); runtimeInitialized = true; - coreTasks.scheduleAll(); - evaluatePhases(maxHealth); - } - - private void initializeAttributes() { - setAttribute(Attribute.MAX_HEALTH, maxHealth); - setAttribute(Attribute.ATTACK_DAMAGE, damage); - if (defense > 0) { - setAttribute(Attribute.ARMOR, defense); - } - attributes.forEach(this::setAttribute); - - AttributeInstance maxHealthAttribute = entity.getAttribute(Attribute.MAX_HEALTH); - if (maxHealthAttribute != null) { - entity.setHealth(maxHealthAttribute.getValue()); - } - - lockEntityProperties(); - } - - private void rehydrateAttributes() { - setAttribute(Attribute.MAX_HEALTH, maxHealth); - setAttribute(Attribute.ATTACK_DAMAGE, damage); - if (defense > 0) { - setAttribute(Attribute.ARMOR, defense); - } - attributes.forEach(this::setAttribute); - - AttributeInstance maxHealthAttribute = entity.getAttribute(Attribute.MAX_HEALTH); - if (maxHealthAttribute != null) { - entity.setHealth(Math.min(entity.getHealth(), maxHealthAttribute.getValue())); - } - - lockEntityProperties(); - } - - private void lockEntityProperties() { - entity.setRemoveWhenFarAway(!isBoss); - entity.setPersistent(isBoss); - entity.setCanPickupItems(false); - } - - private void setAttribute(Attribute attribute, double value) { - AttributeInstance instance = entity.getAttribute(attribute); - if (instance == null) { - return; - } - for (AttributeModifier modifier : new ArrayList<>(instance.getModifiers())) { - instance.removeModifier(modifier); - } - instance.setBaseValue(value); + phaseManager.scheduleAllCoreTasks(); + phaseManager.evaluatePhases(maxHealth); } private void configureEquipment() { - if (armor == null && itemInMainHand == null && itemInOffHand == null) { - return; - } - EntityEquipment equipment = entity.getEquipment(); - if (equipment == null) { - return; - } - - if (armor != null) equipment.setArmorContents(armor); - if (itemInMainHand != null) equipment.setItemInMainHand(itemInMainHand); - if (itemInOffHand != null) equipment.setItemInOffHand(itemInOffHand); - - zeroEquipmentDropChances(equipment); - } - - private void zeroEquipmentDropChances(EntityEquipment equipment) { - equipment.setHelmetDropChance(0f); - equipment.setChestplateDropChance(0f); - equipment.setLeggingsDropChance(0f); - equipment.setBootsDropChance(0f); - equipment.setItemInMainHandDropChance(0f); - equipment.setItemInOffHandDropChance(0f); + equipmentHolder.applyTo(entity); } /** @@ -1233,142 +1374,35 @@ public void addResistance(DamageType type, double percent) { defenses.addResistance(type, percent); } - private void scheduleAbility(LifecycleTaskManager manager, long delayTicks, long periodTicks, Runnable action) { - manager.addRepeatingTask(() -> { - if (!isAlive()) { - return; - } - try { - action.run(); - } catch (Exception exception) { - Log.warn("BlightedEntity", "Ability threw an exception on entity '" + name + "': " + exception.getMessage()); - } - }, delayTicks, periodTicks); - if (canScheduleTask()) manager.scheduleLast(); - } - - private void scheduleDelayedAction(LifecycleTaskManager manager, long delayTicks, Runnable action) { - manager.addDelayedTask(() -> { - if (!isAlive()) { - return; - } - try { - action.run(); - } catch (Exception exception) { - Log.warn("BlightedEntity", "Delayed action threw an exception on entity '" + name + "': " + exception.getMessage()); - } - }, delayTicks); - if (canScheduleTask()) manager.scheduleLast(); - } - - private boolean canScheduleTask() { - return entity != null && !entity.isDead() && runtimeInitialized; - } - - private void createBossBar() { - if (bossBar != null) { - return; - } - if (entityType == EntityType.WITHER || entityType == EntityType.ENDER_DRAGON) { - return; - } - bossBar = Bukkit.createBossBar("§f§l" + name, bossBarColor, bossBarStyle); - bossBar.setProgress(1.0); - } - - private void startBossBarTask() { - addCoreAbility(10L, 20L, this::manageBossBarViewers); - } - - private void manageBossBarViewers() { - if (bossBar == null) { - return; - } - if (!isAlive()) { - removeBossBar(); - return; - } - - updateBossBar(); - - World world = entity.getWorld(); - Location entityLocation = entity.getLocation(); - double rangeSquared = BOSS_BAR_RANGE * BOSS_BAR_RANGE; - - for (Player player : new ArrayList<>(bossBar.getPlayers())) { - if (!player.isOnline() - || player.getWorld() != world - || player.getLocation().distanceSquared(entityLocation) > rangeSquared) { - bossBar.removePlayer(player); - } - } - - for (Player player : world.getPlayers()) { - if (player.getLocation().distanceSquared(entityLocation) <= rangeSquared - && !bossBar.getPlayers().contains(player)) { - bossBar.addPlayer(player); - } - } - } - /** * Returns whether the bound entity is currently valid and alive. * * @return {@code true} if the entity can still be used */ - protected boolean isAlive() { + public boolean isAlive() { return entity != null && entity.isValid() && !entity.isDead(); } /** - * Creates a detached copy of this entity definition. - * - *

The cloned instance is not bound to a Bukkit entity and has no active - * runtime tasks or boss bar.

+ * Creates a pristine, unspawned instance of this entity ready for world spawning. + * Instantiates via {@link fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry}, + * or invokes the zero-argument constructor directly. * - * @return detached clone + * @return fresh unspawned entity instance */ - @Override - public BlightedEntity clone() { - try { - BlightedEntity clone = (BlightedEntity) super.clone(); - clone.entity = null; - clone.bossBar = null; - clone.runtimeInitialized = false; - clone.componentsInitialized = false; - clone.attributes = new HashMap<>(this.attributes); - clone.attachments = new CopyOnWriteArraySet<>(); - clone.defenses = this.defenses.copy(); - clone.coreTasks = new LifecycleTaskManager(); - clone.phaseTasks = new LifecycleTaskManager(); - clone.armor = cloneArmor(); - clone.itemInMainHand = cloneItem(this.itemInMainHand); - clone.itemInOffHand = cloneItem(this.itemInOffHand); - clone.phaseThresholds = new TreeMap<>(this.phaseThresholds); - clone.components = new HashMap<>(); - - for (Map.Entry entry : this.components.entrySet()) { - EntityComponent fresh = AffixRegistry.getAffixById(entry.getKey()); - clone.components.put(entry.getKey(), fresh != null ? fresh : entry.getValue()); - } - - return clone; - } catch (CloneNotSupportedException e) { - throw new RuntimeException("Clone failed", e); + @NonNull + public BlightedEntity createInstance() { + BlightedEntity fresh = EntitiesRegistry.create(getEntityId()); + if (fresh != null) { + return fresh; } - } - - private ItemStack[] cloneArmor() { - if (this.armor == null) { - return null; + try { + var constructor = getClass().getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Exception exception) { + throw new IllegalStateException("Failed to instantiate entity of type " + getClass().getName() + + ". Ensure a public zero-argument constructor is present or register a factory.", exception); } - - ItemStack[] cloned = new ItemStack[this.armor.length]; - for (int i = 0; i < this.armor.length; i++) cloned[i] = cloneItem(this.armor[i]); - return cloned; - } - - private ItemStack cloneItem(ItemStack item) { - return item != null ? item.clone() : null; } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java index ccd2278e..b143d712 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java @@ -4,6 +4,7 @@ import fr.moussax.blightedSMP.engine.items.BlightedItem; import fr.moussax.blightedSMP.engine.loot.LootCondition; import fr.moussax.blightedSMP.engine.loot.LootEntry; +import fr.moussax.blightedSMP.engine.loot.LootResult; import fr.moussax.blightedSMP.engine.loot.LootTable; import fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity; import fr.moussax.blightedSMP.engine.loot.decorators.FeedbackSpecification; @@ -15,17 +16,16 @@ import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.enchantments.Enchantment; +import org.jspecify.annotations.NonNull; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; /** - * Fluent builder for creating {@link LootTable} instances associated with blighted entities. - * - *

Entries added through this builder are selected probabilistically using a - * {@link LootingAwareProbabilisticStrategy} and wrapped with rarity-based feedback.

+ * Fluent builder for configuring probabilistic entity loot tables with rarity feedback. */ public final class EntityLootTableBuilder { @@ -45,571 +45,266 @@ public final class EntityLootTableBuilder { private int maxDrops = 3; /** - * Adds custom blighted item drop with fixed quantity of 1. + * Sets the maximum number of loot drops allowed per roll. * - * @param item custom blighted item - * @param dropChance selection probability (0.0 to 1.0) + * @param maxDrops maximum drop count * @return this builder */ - public EntityLootTableBuilder addLoot(BlightedItem item, double dropChance) { - if (item == null) return this; - return addLoot( - item.getItemId(), - 1, - 1, - dropChance, - EntityLootRarity.COMMON, - LootCondition.alwaysTrue() - ); + public EntityLootTableBuilder maxDrops(int maxDrops) { + this.maxDrops = maxDrops; + return this; } - /** - * Adds custom blighted item drop with fixed quantity of 1 and rarity feedback. - * - * @param item custom blighted item - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(BlightedItem item, double dropChance, EntityLootRarity rarity) { - if (item == null) return this; - return addLoot(item.getItemId(), 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, double dropChance) { + return drop(material, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds custom blighted item drop with quantity range. - * - * @param item custom blighted item - * @param minAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(BlightedItem item, int minAmount, int maximumAmount, double dropChance) { - if (item == null) return this; - return addLoot(item.getItemId(), minAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds custom blighted item drop with quantity range and rarity feedback. - * - * @param item custom blighted item - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(BlightedItem item, int minimumAmount, int maximumAmount, double dropChance, EntityLootRarity rarity) { - if (item == null) return this; - return addLoot(item.getItemId(), minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, int count, double dropChance) { + return drop(material, count, count, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds custom blighted item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param item custom blighted item - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - BlightedItem item, - int minimumAmount, - int maximumAmount, + public EntityLootTableBuilder drop(@NonNull Material material, int count, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, count, count, dropChance, rarity, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder drop(@NonNull Material material, int minAmount, int maxAmount, double dropChance) { + return drop(material, minAmount, maxAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder drop(@NonNull Material material, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, minAmount, maxAmount, dropChance, rarity, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder drop( + @NonNull Material material, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity, - LootCondition condition + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - if (item == null) return this; - return addLoot(item.getItemId(), minimumAmount, maximumAmount, dropChance, rarity, condition); + Objects.requireNonNull(material, "material cannot be null"); + return registerEntry(ItemResult.of(material), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds registered item drop with fixed quantity of 1. - * - * @param itemId registered item identifier - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(String itemId, double dropChance) { - return addLoot(itemId, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, double dropChance) { + return drop(material, modifier, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with fixed quantity of 1 and rarity feedback. - * - * @param itemId registered item identifier - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(String itemId, double dropChance, EntityLootRarity rarity) { - return addLoot(itemId, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, modifier, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with quantity range. - * - * @param itemId registered item identifier - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(String itemId, int minimumAmount, int maximumAmount, double dropChance) { - return addLoot(itemId, minimumAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, int minAmount, int maxAmount, double dropChance) { + return drop(material, modifier, minAmount, maxAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with quantity range and rarity feedback. - * - * @param itemId registered item identifier - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - String itemId, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity - ) { - return addLoot(itemId, minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, modifier, minAmount, maxAmount, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param itemId registered item identifier - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - String itemId, - int minimumAmount, - int maximumAmount, + public EntityLootTableBuilder drop( + @NonNull Material material, + @NonNull Consumer modifier, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity, - LootCondition condition + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.of(itemId), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.range(minimumAmount, maximumAmount), - condition - ) - ); - return this; + Objects.requireNonNull(material, "material cannot be null"); + Objects.requireNonNull(modifier, "modifier cannot be null"); + return registerEntry(ItemResult.of(material, modifier), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds vanilla material item drop with fixed quantity of 1. - * - * @param material item material - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(Material material, double dropChance) { - return addLoot(material, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, double dropChance) { + return drop(itemId, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with fixed quantity of 1 and rarity feedback. - * - * @param material item material - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(Material material, double dropChance, EntityLootRarity rarity) { - return addLoot(material, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(itemId, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with quantity range. - * - * @param material item material - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - int minimumAmount, - int maximumAmount, - double dropChance - ) { - return addLoot(material, minimumAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, int count, double dropChance) { + return drop(itemId, count, count, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with quantity range and rarity feedback. - * - * @param material item material - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity - ) { - return addLoot(material, minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, int count, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(itemId, count, count, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param material item material - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity, - LootCondition condition - ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.of(material), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.range(minimumAmount, maximumAmount), - condition - ) - ); - return this; + public EntityLootTableBuilder drop(@NonNull String itemId, int minAmount, int maxAmount, double dropChance) { + return drop(itemId, minAmount, maxAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds modified material item drop with fixed quantity of 1. - * - * @param material item material - * @param modifier item builder modification function - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(Material material, Consumer modifier, double dropChance) { - return addLoot(material, modifier, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(itemId, minAmount, maxAmount, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds modified material item drop with fixed quantity of 1 and rarity feedback. - * - * @param material item material - * @param modifier item builder modification function - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, + public EntityLootTableBuilder drop( + @NonNull String itemId, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - return addLoot(material, modifier, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + Objects.requireNonNull(itemId, "itemId cannot be null"); + return registerEntry(ItemResult.of(itemId), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds modified material item drop with quantity range. - * - * @param material item material - * @param modifier item builder modification function - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, - int minimumAmount, - int maximumAmount, - double dropChance - ) { - return addLoot(material, modifier, minimumAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull BlightedItem item, double dropChance) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), dropChance); } - /** - * Adds modified material item drop with quantity range and rarity feedback. - * - * @param material item material - * @param modifier item builder modification function - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity - ) { - return addLoot(material, modifier, minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull BlightedItem item, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), dropChance, rarity); } - /** - * Adds modified material item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param material item material - * @param modifier item builder modification function - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, - int minimumAmount, - int maximumAmount, + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int count, double dropChance) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), count, dropChance); + } + + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int count, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), count, dropChance, rarity); + } + + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int minAmount, int maxAmount, double dropChance) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), minAmount, maxAmount, dropChance); + } + + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), minAmount, maxAmount, dropChance, rarity); + } + + public EntityLootTableBuilder drop( + @NonNull BlightedItem item, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity, - LootCondition condition + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.of(material, modifier), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.range(minimumAmount, maximumAmount), - condition - ) - ); - return this; + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds enchanted book drop selected from an enchantment map pool. - * - * @param enchantmentPool map of candidate enchantments to levels - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook(Map enchantmentPool, double dropChance) { - return addEnchantedBook(enchantmentPool, dropChance, EntityLootRarity.COMMON); + public EntityLootTableBuilder gems(int gems, double dropChance) { + return gems(gems, dropChance, EntityLootRarity.COMMON); } - /** - * Adds enchanted book drop selected from an enchantment map pool with rarity feedback. - * - * @param enchantmentPool map of candidate enchantments to levels - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook( - Map enchantmentPool, + public EntityLootTableBuilder gems(int gems, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry(new GemsResult(), gems, gems, dropChance, rarity, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder damagedItem( + @NonNull Material material, + double minPercentage, + double maxPercentage, + double dropChance + ) { + return damagedItem(material, minPercentage, maxPercentage, dropChance, EntityLootRarity.COMMON); + } + + public EntityLootTableBuilder damagedItem( + @NonNull Material material, + double minPercentage, + double maxPercentage, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.randomEnchantedBook(enchantmentPool), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.fixed(1), - LootCondition.alwaysTrue() - ) + Objects.requireNonNull(material, "material cannot be null"); + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry( + ItemResult.randomDurability(material, minPercentage, maxPercentage), + 1, 1, dropChance, rarity, LootCondition.alwaysTrue() ); - return this; } - /** - * Adds enchanted book drop with level range selected from candidate enchantments. - * - * @param enchantments list of candidate enchantments - * @param minimumLevel minimum enchantment level - * @param maximumLevel maximum enchantment level - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook( - List enchantments, - int minimumLevel, - int maximumLevel, + public EntityLootTableBuilder enchantedBook( + @NonNull List enchantments, + int minLevel, + int maxLevel, double dropChance ) { - return addEnchantedBook(enchantments, minimumLevel, maximumLevel, dropChance, EntityLootRarity.COMMON); + return enchantedBook(enchantments, minLevel, maxLevel, dropChance, EntityLootRarity.COMMON); } - /** - * Adds enchanted book drop with level range selected from candidate enchantments with rarity feedback. - * - * @param enchantments list of candidate enchantments - * @param minimumLevel minimum enchantment level - * @param maximumLevel maximum enchantment level - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook( - List enchantments, - int minimumLevel, - int maximumLevel, + public EntityLootTableBuilder enchantedBook( + @NonNull List enchantments, + int minLevel, + int maxLevel, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.randomEnchantedBook(enchantments, minimumLevel, maximumLevel), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.fixed(1), - LootCondition.alwaysTrue() - ) + Objects.requireNonNull(enchantments, "enchantments cannot be null"); + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry( + ItemResult.randomEnchantedBook(enchantments, minLevel, maxLevel), + 1, 1, dropChance, rarity, LootCondition.alwaysTrue() ); - return this; } - /** - * Adds item drop with durability rolled in a percentage range. - * - * @param material item material - * @param minimumPercentage minimum durability percentage - * @param maximumPercentage maximum durability percentage - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addDamagedItem( - Material material, - double minimumPercentage, - double maximumPercentage, + public EntityLootTableBuilder enchantedBook( + @NonNull Map enchantmentPool, double dropChance ) { - return addDamagedItem(material, minimumPercentage, maximumPercentage, dropChance, EntityLootRarity.COMMON); + return enchantedBook(enchantmentPool, dropChance, EntityLootRarity.COMMON); } - /** - * Adds item drop with durability rolled in a percentage range with rarity feedback. - * - * @param material item material - * @param minimumPercentage minimum durability percentage - * @param maximumPercentage maximum durability percentage - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addDamagedItem( - Material material, - double minimumPercentage, - double maximumPercentage, + public EntityLootTableBuilder enchantedBook( + @NonNull Map enchantmentPool, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.randomDurability( - material, - minimumPercentage, - maximumPercentage - ), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.fixed(1), - LootCondition.alwaysTrue() - ) + Objects.requireNonNull(enchantmentPool, "enchantmentPool cannot be null"); + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry( + ItemResult.randomEnchantedBook(enchantmentPool), + 1, 1, dropChance, rarity, LootCondition.alwaysTrue() ); - return this; } - /** - * Adds gem reward drop. - * - * @param gems gem reward quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addGems(int gems, double dropChance) { - return addGems(gems, dropChance, EntityLootRarity.COMMON); - } + private EntityLootTableBuilder registerEntry( + LootResult result, + int minAmount, + int maxAmount, + double dropChance, + EntityLootRarity rarity, + LootCondition condition + ) { + AmountProvider amountProvider = (minAmount == maxAmount) + ? AmountProvider.fixed(minAmount) + : AmountProvider.range(minAmount, maxAmount); - /** - * Adds gem reward drop with rarity feedback. - * - * @param gems gem reward quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addGems(int gems, double dropChance, EntityLootRarity rarity) { builder.addEntry( LootEntry.probabilistic( - new GenericFeedbackDecorator<>(new GemsResult(), rarity, ENTITY_FEEDBACK_MAPPER), + new GenericFeedbackDecorator<>(result, rarity, ENTITY_FEEDBACK_MAPPER), dropChance, - AmountProvider.fixed(gems), - LootCondition.alwaysTrue() + amountProvider, + condition ) ); return this; } - /** - * Sets maximum number of loot drops allowed per roll. - * - * @param maxDrops maximum drop count - * @return this builder - */ - public EntityLootTableBuilder maxDrops(int maxDrops) { - this.maxDrops = maxDrops; - return this; - } - - /** - * Sets maximum number of loot drops allowed per roll. - * - * @param maxDrops maximum drop count - * @return this builder - */ - public EntityLootTableBuilder setMaxDrops(int maxDrops) { - this.maxDrops = maxDrops; - return this; - } - /** * Constructs configured {@link LootTable} instance. * * @return new entity loot table */ + @NonNull public LootTable build() { return builder .selectionStrategy(new LootingAwareProbabilisticStrategy(maxDrops)) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java index d6760754..ad0fa7b8 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java @@ -19,7 +19,9 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.*; +import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.ENTITY_ID_KEY; +import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.FAST_PASS_TAG; +import static fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager.*; /** * Central runtime entity manager tracking active {@link BlightedEntity} instances, @@ -198,10 +200,10 @@ public static void rehydrateChunk(Chunk chunk) { } String entityId = persistentDataContainer.get(ENTITY_ID_KEY, PersistentDataType.STRING); - BlightedEntity prototype = EntitiesRegistry.get(entityId); - if (prototype == null) continue; + BlightedEntity blighted = EntitiesRegistry.create(entityId); + if (blighted == null) continue; - prototype.clone().attachToExisting(living); + blighted.attachToExisting(living); } // Pass 2: re-register attachment entities carrying ATTACHMENT_OWNER_KEY. @@ -246,9 +248,8 @@ public static void rehydrateChunk(Chunk chunk) { boolean syncYaw = yawByte == null || yawByte == 1; boolean syncPitch = pitchByte != null && pitchByte == 1; - owner.attachments.removeIf(attachment -> attachment.entity() != null - && attachment.entity().getUniqueId().equals(entity.getUniqueId())); - owner.attachments.add(new EntityAttachment(entity, role, offset, syncYaw, syncPitch)); + owner.getAttachmentManager().registerRehydratedAttachment( + new EntityAttachment(entity, role, offset, syncYaw, syncPitch)); registerAttachment(entity, owner); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java new file mode 100644 index 00000000..96d07024 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java @@ -0,0 +1,401 @@ +package fr.moussax.blightedSMP.engine.entities.attachment; + +import fr.moussax.blightedSMP.BlightedSMP; +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import fr.moussax.blightedSMP.engine.entities.EntityManager; +import fr.moussax.blightedSMP.engine.entities.equipment.EntityEquipmentHolder; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.*; +import org.bukkit.inventory.EntityEquipment; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Vector; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.function.Consumer; + +/** + * Manages multipart and visual attachments bound to a parent {@link BlightedEntity}. + * + *

Handles local 3D offset calculations, relative yaw/pitch rotation tracking, + * persistent data tagging, and lifecycle cleanup.

+ */ +public final class EntityAttachmentManager { + + public static final NamespacedKey ATTACHMENT_OWNER_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_owner"); + public static final NamespacedKey ATTACHMENT_ROLE_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_role"); + public static final NamespacedKey ATTACHMENT_OFFSET_X_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_x"); + public static final NamespacedKey ATTACHMENT_OFFSET_Y_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_y"); + public static final NamespacedKey ATTACHMENT_OFFSET_Z_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_z"); + public static final NamespacedKey ATTACHMENT_SYNC_YAW_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_yaw"); + public static final NamespacedKey ATTACHMENT_SYNC_PITCH_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_pitch"); + + private final BlightedEntity owner; + private final Set attachments = new CopyOnWriteArraySet<>(); + + public EntityAttachmentManager(@NonNull BlightedEntity owner) { + this.owner = Objects.requireNonNull(owner, "owner cannot be null"); + } + + /** + * Attaches a non-interactive {@link ItemDisplay} entity with local 3D translation offset. + * + * @param offset local offset relative to base entity origin and facing yaw + * @param configurator optional configuration consumer + * @return the created ItemDisplay attachment, or null if entity is not alive + */ + @Nullable + public ItemDisplay attachItemDisplay(@Nullable Vector offset, @Nullable Consumer configurator) { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead()) { + return null; + } + + Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); + Location spawnLoc = live.getLocation().clone().add(localOffset); + ItemDisplay display = live.getWorld().spawn(spawnLoc, ItemDisplay.class, itemDisplay -> { + if (configurator != null) { + configurator.accept(itemDisplay); + } + }); + addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); + return display; + } + + /** + * Attaches a non-interactive {@link BlockDisplay} entity with local 3D translation offset. + * + * @param offset local offset relative to base entity origin and facing yaw + * @param configurator optional configuration consumer + * @return the created BlockDisplay attachment, or null if entity is not alive + */ + @Nullable + public BlockDisplay attachBlockDisplay(@Nullable Vector offset, @Nullable Consumer configurator) { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead()) { + return null; + } + + Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); + Location spawnLoc = live.getLocation().clone().add(localOffset); + BlockDisplay display = live.getWorld().spawn(spawnLoc, BlockDisplay.class, blockDisplay -> { + if (configurator != null) { + configurator.accept(blockDisplay); + } + }); + addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); + return display; + } + + /** + * Attaches a multipart hittable {@link Interaction} hitbox entity. + * + * @param offset local offset relative to base entity origin and facing yaw + * @param width hitbox width + * @param height hitbox height + * @param configurator optional configuration consumer + * @return the created Interaction attachment, or null if entity is not alive + */ + @Nullable + public Interaction attachHitbox( + @Nullable Vector offset, + float width, + float height, + @Nullable Consumer configurator + ) { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead()) { + return null; + } + + Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); + Location spawnLocation = live.getLocation().clone().add(localOffset); + Interaction interaction = live.getWorld().spawn(spawnLocation, Interaction.class, hitbox -> { + hitbox.setInteractionWidth(width); + hitbox.setInteractionHeight(height); + if (configurator != null) { + configurator.accept(hitbox); + } + }); + addAttachment(interaction, AttachmentRole.HITBOX, localOffset, true, false); + return interaction; + } + + /** + * Attaches an entity using {@link AttachmentRole#SUBORDINATE}. + * + * @param attachmentEntity entity to attach + */ + public void addAttachment(@Nullable Entity attachmentEntity) { + addAttachment(attachmentEntity, AttachmentRole.SUBORDINATE, new Vector(0, 0, 0), true, false); + } + + /** + * Attaches an entity with the given role. + * + * @param attachmentEntity entity to attach + * @param role attachment role + */ + public void addAttachment(@Nullable Entity attachmentEntity, @NonNull AttachmentRole role) { + addAttachment(attachmentEntity, role, new Vector(0, 0, 0), true, false); + } + + /** + * Attaches an entity with the given role and local offset. + * + * @param attachmentEntity entity to attach + * @param role attachment role + * @param offset local 3D offset + */ + public void addAttachment(@Nullable Entity attachmentEntity, @NonNull AttachmentRole role, @Nullable Vector offset) { + addAttachment(attachmentEntity, role, offset, true, false); + } + + /** + * Attaches an entity with full offset and rotation synchronization configuration. + * + * @param attachmentEntity entity to attach + * @param role attachment role + * @param offset local 3D offset + * @param syncYaw whether horizontal rotation follows base yaw + * @param syncPitch whether vertical rotation follows base pitch + */ + public void addAttachment( + @Nullable Entity attachmentEntity, + @NonNull AttachmentRole role, + @Nullable Vector offset, + boolean syncYaw, + boolean syncPitch + ) { + if (attachmentEntity == null) { + return; + } + + Vector vector = offset != null ? offset : new Vector(0, 0, 0); + attachments.add(new EntityAttachment(attachmentEntity, role, vector, syncYaw, syncPitch)); + EntityManager.registerAttachment(attachmentEntity, owner); + + LivingEntity live = owner.getEntity(); + if (live != null) { + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OWNER_KEY, PersistentDataType.STRING, live.getUniqueId().toString()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_ROLE_KEY, PersistentDataType.STRING, role.name()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OFFSET_X_KEY, PersistentDataType.DOUBLE, vector.getX()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OFFSET_Y_KEY, PersistentDataType.DOUBLE, vector.getY()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OFFSET_Z_KEY, PersistentDataType.DOUBLE, vector.getZ()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_SYNC_YAW_KEY, PersistentDataType.BYTE, (byte) (syncYaw ? 1 : 0)); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_SYNC_PITCH_KEY, PersistentDataType.BYTE, (byte) (syncPitch ? 1 : 0)); + } + + if (attachmentEntity instanceof Display display) { + display.setTeleportDuration(1); + display.setInterpolationDuration(1); + } + + if (attachmentEntity instanceof LivingEntity living) { + EntityEquipment equipment = living.getEquipment(); + if (equipment != null) { + EntityEquipmentHolder.zeroEquipmentDropChances(equipment); + } + } + attachmentEntity.addScoreboardTag(BlightedEntity.FAST_PASS_TAG); + } + + /** + * Synchronizes all registered attachments to their relative world position based on base location and facing yaw. + */ + public void syncAttachments() { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead() || attachments.isEmpty()) { + return; + } + + Location baseLocation = live.getLocation(); + double radians = Math.toRadians(baseLocation.getYaw()); + double cos = Math.cos(radians); + double sin = Math.sin(radians); + + for (EntityAttachment attachment : attachments) { + Entity attachedEntity = attachment.entity(); + if (attachedEntity == null || !attachedEntity.isValid() || attachedEntity.isDead()) { + attachments.remove(attachment); + if (attachedEntity != null) { + EntityManager.unregisterAttachment(attachedEntity); + } + continue; + } + + if (attachment.role() == AttachmentRole.SUBORDINATE) { + continue; + } + + if (live.getPassengers().contains(attachedEntity)) { + continue; + } + + Vector offset = attachment.localOffset(); + double xPrime = baseLocation.getX() + (offset.getX() * cos - offset.getZ() * sin); + double zPrime = baseLocation.getZ() + (offset.getX() * sin + offset.getZ() * cos); + double yPrime = baseLocation.getY() + offset.getY(); + + Location currentLocation = attachedEntity.getLocation(); + float targetYaw = attachment.syncYaw() ? baseLocation.getYaw() : currentLocation.getYaw(); + float targetPitch = attachment.syncPitch() ? baseLocation.getPitch() : currentLocation.getPitch(); + + if (currentLocation.getWorld() == baseLocation.getWorld() + && Math.abs(currentLocation.getX() - xPrime) < 0.001 + && Math.abs(currentLocation.getY() - yPrime) < 0.001 + && Math.abs(currentLocation.getZ() - zPrime) < 0.001 + && Math.abs(currentLocation.getYaw() - targetYaw) < 0.1f + && Math.abs(currentLocation.getPitch() - targetPitch) < 0.1f) { + continue; + } + + Location targetLocation = new Location(baseLocation.getWorld(), xPrime, yPrime, zPrime, targetYaw, targetPitch); + attachedEntity.teleport(targetLocation); + } + } + + /** + * Removes and destroys all attached entities. + */ + public void killAllAttachments() { + if (attachments.isEmpty()) { + return; + } + + for (EntityAttachment attachment : attachments) { + Entity attachmentEntity = attachment.entity(); + if (attachmentEntity == null) { + continue; + } + + EntityManager.unregisterAttachment(attachmentEntity); + attachmentEntity.remove(); + } + attachments.clear(); + } + + /** + * Removes and destroys attached entities matching the specified role. + * + * @param role attachment role to remove + */ + public void killAttachments(@Nullable AttachmentRole role) { + if (attachments.isEmpty() || role == null) { + return; + } + + attachments.removeIf(attachment -> { + if (attachment.role() != role) { + return false; + } + Entity attachmentEntity = attachment.entity(); + if (attachmentEntity != null) { + EntityManager.unregisterAttachment(attachmentEntity); + attachmentEntity.remove(); + } + return true; + }); + } + + /** + * Checks whether a living body attachment is currently present. + * + * @return {@code true} if a living body attachment exists + */ + public boolean hasLivingBodyAttachment() { + for (EntityAttachment attachment : attachments) { + if (attachment.entity() instanceof LivingEntity living && !living.isDead()) { + return true; + } + } + return false; + } + + /** + * Checks whether subordinate attachments are present. + * + * @return {@code true} if subordinate attachments exist + */ + public boolean hasSubordinateAttachments() { + for (EntityAttachment attachment : attachments) { + if (attachment.role() == AttachmentRole.SUBORDINATE && attachment.entity().isValid()) { + return true; + } + } + return false; + } + + private long lastDamageTick = -1; + private java.util.UUID lastDamagerUuid = null; + + /** + * Prevents multi-hitbox attachments from applying double damage from the same attack in the same tick. + * + * @param damager attacking entity + * @return {@code true} if damage should be blocked + */ + public boolean shouldBlockSameTickDamage(@Nullable Entity damager) { + if (damager == null) { + return false; + } + LivingEntity live = owner.getEntity(); + long currentTick = live != null + ? live.getWorld().getGameTime() + : System.currentTimeMillis(); + java.util.UUID damagerUuid = damager.getUniqueId(); + + if (currentTick == lastDamageTick && java.util.Objects.equals(damagerUuid, lastDamagerUuid)) { + return true; + } + + lastDamageTick = currentTick; + lastDamagerUuid = damagerUuid; + return false; + } + + /** + * Returns an unmodifiable snapshot view of all registered attachments. + * + * @return registered attachments + */ + public Set getAttachments() { + return Collections.unmodifiableSet(attachments); + } + + /** + * Re-registers an attachment discovered during chunk rehydration. + * + * @param attachment rehydrated entity attachment + */ + public void registerRehydratedAttachment(@NonNull EntityAttachment attachment) { + if (attachment.entity() != null) { + attachments.removeIf(existingAttachment -> existingAttachment.entity() != null + && existingAttachment.entity().getUniqueId().equals(attachment.entity().getUniqueId())); + } + attachments.add(attachment); + } + + public void clear() { + attachments.clear(); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java new file mode 100644 index 00000000..4b648194 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java @@ -0,0 +1,50 @@ +package fr.moussax.blightedSMP.engine.entities.boss; + +import lombok.Getter; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.jspecify.annotations.NonNull; + +/** + * Fluent builder for configuring boss bar appearance and view distance. + */ +@Getter +public final class BossBarBuilder { + + private BarColor color = BarColor.RED; + private BarStyle style = BarStyle.SOLID; + private double viewRadius = EntityBossBarController.DEFAULT_VIEW_RADIUS; + + /** + * Sets the bar display color. + * + * @param color boss bar color + * @return this builder + */ + public BossBarBuilder color(@NonNull BarColor color) { + this.color = color; + return this; + } + + /** + * Sets the segmentation style of the boss bar. + * + * @param style boss bar style + * @return this builder + */ + public BossBarBuilder style(@NonNull BarStyle style) { + this.style = style; + return this; + } + + /** + * Sets the player detection radius in blocks within which the boss bar is visible. + * + * @param viewRadius visibility radius in blocks + * @return this builder + */ + public BossBarBuilder viewRadius(double viewRadius) { + this.viewRadius = viewRadius; + return this; + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java new file mode 100644 index 00000000..0640ad11 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java @@ -0,0 +1,197 @@ +package fr.moussax.blightedSMP.engine.entities.boss; + +import lombok.Getter; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Manages the lifecycle, appearance, periodic progress synchronization, and proximity viewer tracking of an entity's {@link BossBar}. + */ +public final class EntityBossBarController { + + public static final double DEFAULT_VIEW_RADIUS = 60.0; + private static final long SCAN_PERIOD_TICKS = 20L; + + private final Plugin plugin; + private final Supplier liveEntitySupplier; + private final Supplier titleSupplier; + private final Supplier entityTypeSupplier; + + private BossBar bossBar; + @Getter + private BarColor color = BarColor.RED; + @Getter + private BarStyle style = BarStyle.SOLID; + @Getter + private double viewRadius = DEFAULT_VIEW_RADIUS; + private BukkitTask viewerTask; + + public EntityBossBarController( + @NonNull Plugin plugin, + @NonNull Supplier liveEntitySupplier, + @NonNull Supplier titleSupplier, + @NonNull Supplier entityTypeSupplier + ) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + this.liveEntitySupplier = Objects.requireNonNull(liveEntitySupplier, "liveEntitySupplier cannot be null"); + this.titleSupplier = Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null"); + this.entityTypeSupplier = Objects.requireNonNull(entityTypeSupplier, "entityTypeSupplier cannot be null"); + } + + /** + * Creates and initializes the Bukkit boss bar if not already created. + * Wither and Ender Dragon types manage their own boss bars natively and are skipped. + */ + public void create() { + if (bossBar != null) { + return; + } + + EntityType type = entityTypeSupplier.get(); + if (type == EntityType.WITHER || type == EntityType.ENDER_DRAGON) { + return; + } + + bossBar = Bukkit.createBossBar("§f§l" + titleSupplier.get(), color, style); + bossBar.setProgress(1.0); + startViewerTask(); + } + + /** + * Updates the boss bar fill progress based on the entity's current health ratio. + * + * @param progress value in range [0.0, 1.0] + */ + public void updateProgress(double progress) { + if (bossBar == null) { + return; + } + bossBar.setProgress(Math.clamp(progress, 0.0, 1.0)); + } + + /** + * Configures the boss bar's color and segment style. + * + * @param color bar color + * @param style bar style + */ + public void setAppearance(@NonNull BarColor color, @NonNull BarStyle style) { + this.color = Objects.requireNonNull(color, "color cannot be null"); + this.style = Objects.requireNonNull(style, "style cannot be null"); + if (bossBar != null) { + bossBar.setColor(color); + bossBar.setStyle(style); + } + } + + /** + * Configures settings from a {@link BossBarBuilder}. + * + * @param builder boss bar builder + */ + public void configure(@NonNull BossBarBuilder builder) { + Objects.requireNonNull(builder, "builder cannot be null"); + setAppearance(builder.getColor(), builder.getStyle()); + this.viewRadius = builder.getViewRadius(); + } + + /** + * Removes all viewers, cancels the periodic scanning task, and destroys the boss bar. + */ + public void remove() { + if (viewerTask != null) { + viewerTask.cancel(); + viewerTask = null; + } + if (bossBar != null) { + bossBar.removeAll(); + bossBar = null; + } + } + + /** + * Checks if the boss bar is currently active. + * + * @return {@code true} if active + */ + public boolean isActive() { + return bossBar != null; + } + + @Nullable + public BossBar getBukkitBar() { + return bossBar; + } + + public void setViewRadius(double viewRadius) { + this.viewRadius = Math.max(1.0, viewRadius); + } + + private void startViewerTask() { + if (viewerTask != null) { + viewerTask.cancel(); + } + viewerTask = Bukkit.getScheduler().runTaskTimer(plugin, this::manageViewers, 0L, SCAN_PERIOD_TICKS); + } + + private void manageViewers() { + if (bossBar == null) { + if (viewerTask != null) { + viewerTask.cancel(); + viewerTask = null; + } + return; + } + + LivingEntity entity = liveEntitySupplier.get(); + if (entity == null || !entity.isValid() || entity.isDead()) { + remove(); + return; + } + + // Periodic health synchronization + AttributeInstance maxHealthAttribute = entity.getAttribute(Attribute.MAX_HEALTH); + double maxHealth = maxHealthAttribute != null ? Math.max(1.0, maxHealthAttribute.getValue()) : 20.0; + double currentHealth = Math.clamp(entity.getHealth(), 0.0, maxHealth); + bossBar.setProgress(currentHealth / maxHealth); + + Location entityLocation = entity.getLocation(); + if (entityLocation.getWorld() == null) { + return; + } + + double radiusSquared = viewRadius * viewRadius; + + for (Player player : new ArrayList<>(bossBar.getPlayers())) { + if (!player.isOnline() + || !player.getWorld().equals(entityLocation.getWorld()) + || player.getLocation().distanceSquared(entityLocation) > radiusSquared) { + bossBar.removePlayer(player); + } + } + + for (Player player : entityLocation.getWorld().getPlayers()) { + if (player.isValid() + && player.getLocation().distanceSquared(entityLocation) <= radiusSquared + && !bossBar.getPlayers().contains(player)) { + bossBar.addPlayer(player); + } + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java new file mode 100644 index 00000000..bf2e0d99 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java @@ -0,0 +1,150 @@ +package fr.moussax.blightedSMP.engine.entities.components; + +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import lombok.Getter; +import org.bukkit.Location; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; + +/** + * Manages the registration, lifecycle, lookup, and periodic ticking of {@link EntityComponent}s. + */ +public final class EntityComponentManager { + + private final Map components = new HashMap<>(); + @Getter + private boolean initialized = false; + + public EntityComponentManager() { + } + + private EntityComponentManager(Map source) { + for (Map.Entry entry : source.entrySet()) { + EntityComponent fresh = AffixRegistry.getAffixById(entry.getKey()); + this.components.put(entry.getKey(), fresh != null ? fresh : entry.getValue()); + } + } + + /** + * Registers a component. If components have already been initialized, the component + * is immediately initialized against the live entity. + * + * @param component component to register + * @param liveEntity current live entity, or null if unspawned + */ + public void addComponent(@NonNull EntityComponent component, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(component, "component cannot be null"); + components.put(component.getId(), component); + if (initialized && liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + component.onInit(liveEntity); + } + } + + /** + * Retrieves a registered component by its unique string identifier. + * + * @param id component identifier + * @param expected component type + * @return component instance, or null if not registered + */ + @Nullable + @SuppressWarnings("unchecked") + public T getComponent(@NonNull String id) { + return (T) components.get(id); + } + + /** + * Retrieves the first registered component matching the specified class. + * + * @param componentClass class to match + * @param expected component type + * @return matching component instance, or null if none registered + */ + @Nullable + public T getComponent(@Nullable Class componentClass) { + if (componentClass == null) { + return null; + } + for (EntityComponent component : components.values()) { + if (componentClass.isInstance(component)) { + return componentClass.cast(component); + } + } + return null; + } + + /** + * Returns a snapshot of all currently registered components. + * + * @return list of registered components + */ + @NonNull + public Collection getComponents() { + return new ArrayList<>(components.values()); + } + + /** + * Initializes all registered components against the live entity. + * + * @param liveEntity bound entity + */ + public void init(@Nullable LivingEntity liveEntity) { + if (initialized || liveEntity == null) { + return; + } + initialized = true; + for (EntityComponent component : components.values()) { + component.onInit(liveEntity); + } + } + + /** + * Destroys all registered components. + * + * @param liveEntity bound entity + */ + public void destroy(@Nullable LivingEntity liveEntity) { + for (EntityComponent component : components.values()) { + component.onDestroy(liveEntity); + } + initialized = false; + } + + /** + * Executes periodic tick logic for all components. + * + * @param entity owner entity + */ + public void tick(@NonNull BlightedEntity entity) { + for (EntityComponent component : components.values()) { + component.onTick(entity); + } + } + + /** + * Notifies all components of the entity's death. + * + * @param entity owner entity + * @param location death location + */ + public void onDeath(@NonNull BlightedEntity entity, @Nullable Location location) { + if (location != null) { + for (EntityComponent component : components.values()) { + component.onDeath(entity, location); + } + } + } + + /** + * Creates an uninitialized detached copy of this component manager with freshly instantiated affixes. + * + * @return detached copy + */ + @NonNull + public EntityComponentManager copy() { + return new EntityComponentManager(this.components); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java new file mode 100644 index 00000000..66b18376 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java @@ -0,0 +1,41 @@ +package fr.moussax.blightedSMP.engine.entities.defense; + +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import org.jspecify.annotations.NonNull; + +/** + * Fluent builder for configuring mob damage immunities and resistances. + */ +public final class DefensesBuilder { + + private final BlightedEntity entity; + + public DefensesBuilder(@NonNull BlightedEntity entity) { + this.entity = entity; + } + + /** + * Registers total damage immunity against the specified damage types. + * + * @param types damage types to completely negate + * @return this builder + */ + public DefensesBuilder immune(@NonNull DamageType... types) { + for (DamageType type : types) { + entity.addImmunity(type); + } + return this; + } + + /** + * Registers proportional damage reduction against the specified damage type. + * + * @param type damage type to resist + * @param percent percentage of damage resisted in range [0.0, 100.0] (e.g. 50.0 for 50% reduction) + * @return this builder + */ + public DefensesBuilder resist(@NonNull DamageType type, double percent) { + entity.addResistance(type, percent); + return this; + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java new file mode 100644 index 00000000..e8668998 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java @@ -0,0 +1,170 @@ +package fr.moussax.blightedSMP.engine.entities.equipment; + +import lombok.Getter; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Fluent builder for configuring mob armor and hand equipment. + */ +@Getter +public final class EntityEquipmentBuilder { + + private ItemStack helmet; + private ItemStack chestplate; + private ItemStack leggings; + private ItemStack boots; + private ItemStack mainHand; + private ItemStack offHand; + + /** + * Sets the helmet item stack. + * + * @param helmet helmet item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder helmet(@Nullable ItemStack helmet) { + this.helmet = helmet; + return this; + } + + /** + * Sets the helmet to a new item of the given material. + * + * @param material helmet material + * @return this builder + */ + public EntityEquipmentBuilder helmet(@NonNull Material material) { + return helmet(new ItemStack(material)); + } + + /** + * Sets the chestplate item stack. + * + * @param chestplate chestplate item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder chestplate(@Nullable ItemStack chestplate) { + this.chestplate = chestplate; + return this; + } + + /** + * Sets the chestplate to a new item of the given material. + * + * @param material chestplate material + * @return this builder + */ + public EntityEquipmentBuilder chestplate(@NonNull Material material) { + return chestplate(new ItemStack(material)); + } + + /** + * Sets the leggings item stack. + * + * @param leggings leggings item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder leggings(@Nullable ItemStack leggings) { + this.leggings = leggings; + return this; + } + + /** + * Sets the leggings to a new item of the given material. + * + * @param material leggings material + * @return this builder + */ + public EntityEquipmentBuilder leggings(@NonNull Material material) { + return leggings(new ItemStack(material)); + } + + /** + * Sets the boots item stack. + * + * @param boots boots item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder boots(@Nullable ItemStack boots) { + this.boots = boots; + return this; + } + + /** + * Sets the boots to a new item of the given material. + * + * @param material boots material + * @return this builder + */ + public EntityEquipmentBuilder boots(@NonNull Material material) { + return boots(new ItemStack(material)); + } + + /** + * Sets all four armor slots in a single call. + * + * @param helmet helmet item, or {@code null} + * @param chestplate chestplate item, or {@code null} + * @param leggings leggings item, or {@code null} + * @param boots boots item, or {@code null} + * @return this builder + */ + public EntityEquipmentBuilder armor( + @Nullable ItemStack helmet, + @Nullable ItemStack chestplate, + @Nullable ItemStack leggings, + @Nullable ItemStack boots + ) { + this.helmet = helmet; + this.chestplate = chestplate; + this.leggings = leggings; + this.boots = boots; + return this; + } + + /** + * Sets the main hand item stack. + * + * @param mainHand main hand item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder mainHand(@Nullable ItemStack mainHand) { + this.mainHand = mainHand; + return this; + } + + /** + * Sets the main hand to a new item of the given material. + * + * @param material main hand material + * @return this builder + */ + public EntityEquipmentBuilder mainHand(@NonNull Material material) { + return mainHand(new ItemStack(material)); + } + + /** + * Sets the off hand item stack. + * + * @param offHand off hand item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder offHand(@Nullable ItemStack offHand) { + this.offHand = offHand; + return this; + } + + /** + * Sets the off hand to a new item of the given material. + * + * @param material off hand material + * @return this builder + */ + public EntityEquipmentBuilder offHand(@NonNull Material material) { + return offHand(new ItemStack(material)); + } + +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java new file mode 100644 index 00000000..80e4c681 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java @@ -0,0 +1,366 @@ +package fr.moussax.blightedSMP.engine.entities.equipment; + +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.EntityEquipment; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Manages equipment state for custom entities, holding template items and synchronizing with live Bukkit entities. + */ +public final class EntityEquipmentHolder { + + private ItemStack[] armor; + private ItemStack itemInMainHand; + private ItemStack itemInOffHand; + + public EntityEquipmentHolder() { + } + + private void ensureArmorArray() { + if (armor == null) { + armor = new ItemStack[4]; + } + } + + /** + * Sets the helmet item and synchronizes it with the live entity if active. + * + * @param helmet helmet item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setHelmet(@Nullable ItemStack helmet, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[3] = helmet; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setHelmet(helmet); + } + } + + /** + * Sets the chestplate item and synchronizes it with the live entity if active. + * + * @param chestplate chestplate item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setChestplate(@Nullable ItemStack chestplate, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[2] = chestplate; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setChestplate(chestplate); + } + } + + /** + * Sets the leggings item and synchronizes it with the live entity if active. + * + * @param leggings leggings item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setLeggings(@Nullable ItemStack leggings, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[1] = leggings; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setLeggings(leggings); + } + } + + /** + * Sets the boots item and synchronizes it with the live entity if active. + * + * @param boots boots item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setBoots(@Nullable ItemStack boots, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[0] = boots; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setBoots(boots); + } + } + + /** + * Sets all four armor slots and synchronizes them with the live entity if active. + * + * @param helmet helmet item, or {@code null} + * @param chestplate chestplate item, or {@code null} + * @param leggings leggings item, or {@code null} + * @param boots boots item, or {@code null} + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setArmor( + @Nullable ItemStack helmet, + @Nullable ItemStack chestplate, + @Nullable ItemStack leggings, + @Nullable ItemStack boots, + @Nullable LivingEntity liveEntity + ) { + setHelmet(helmet, liveEntity); + setChestplate(chestplate, liveEntity); + setLeggings(leggings, liveEntity); + setBoots(boots, liveEntity); + } + + /** + * Clears all armor slots and strips armor from the live entity if active. + * + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void clearArmor(@Nullable LivingEntity liveEntity) { + this.armor = null; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setArmorContents(new ItemStack[4]); + } + } + + /** + * Clears all armor and hand items and empties equipment on the live entity if active. + * + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void clearEquipment(@Nullable LivingEntity liveEntity) { + this.armor = null; + this.itemInMainHand = null; + this.itemInOffHand = null; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().clear(); + } + } + + /** + * Sets the main hand item and synchronizes it with the live entity if active. + * + * @param item main hand item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setMainHand(@Nullable ItemStack item, @Nullable LivingEntity liveEntity) { + this.itemInMainHand = item; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setItemInMainHand(item); + } + } + + /** + * Sets the off hand item and synchronizes it with the live entity if active. + * + * @param item off hand item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setOffHand(@Nullable ItemStack item, @Nullable LivingEntity liveEntity) { + this.itemInOffHand = item; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setItemInOffHand(item); + } + } + + /** + * Toggles whether the configured main hand item is currently equipped on the live entity. + * + * @param liveEntity live entity to update, or {@code null} + * @param equipped {@code true} to equip the item, {@code false} to temporarily unequip + */ + public void setMainHandEquipped(@Nullable LivingEntity liveEntity, boolean equipped) { + if (!isEntityActive(liveEntity) || liveEntity.getEquipment() == null) { + return; + } + liveEntity.getEquipment().setItemInMainHand(equipped ? itemInMainHand : null); + } + + /** + * Toggles whether the configured off hand item is currently equipped on the live entity. + * + * @param liveEntity live entity to update, or {@code null} + * @param equipped {@code true} to equip the item, {@code false} to temporarily unequip + */ + public void setOffHandEquipped(@Nullable LivingEntity liveEntity, boolean equipped) { + if (!isEntityActive(liveEntity) || liveEntity.getEquipment() == null) { + return; + } + liveEntity.getEquipment().setItemInOffHand(equipped ? itemInOffHand : null); + } + + /** + * Returns the template item held in the main hand. + * + * @return main hand template item, or {@code null} if empty + */ + @Nullable + public ItemStack getItemInMainHand() { + return itemInMainHand; + } + + /** + * Returns the template item held in the off hand. + * + * @return off hand template item, or {@code null} if empty + */ + @Nullable + public ItemStack getItemInOffHand() { + return itemInOffHand; + } + + /** + * Retrieves the current helmet from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return helmet item, or {@code null} if empty + */ + @Nullable + public ItemStack getHelmet(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getHelmet(); + } + return armor != null && armor.length > 3 ? armor[3] : null; + } + + /** + * Retrieves the current chestplate from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return chestplate item, or {@code null} if empty + */ + @Nullable + public ItemStack getChestplate(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getChestplate(); + } + return armor != null && armor.length > 2 ? armor[2] : null; + } + + /** + * Retrieves the current leggings from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return leggings item, or {@code null} if empty + */ + @Nullable + public ItemStack getLeggings(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getLeggings(); + } + return armor != null && armor.length > 1 ? armor[1] : null; + } + + /** + * Retrieves the current boots from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return boots item, or {@code null} if empty + */ + @Nullable + public ItemStack getBoots(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getBoots(); + } + return armor != null && armor.length > 0 ? armor[0] : null; + } + + /** + * Retrieves the current main hand item from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return main hand item, or {@code null} if empty + */ + @Nullable + public ItemStack getMainHand(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getItemInMainHand(); + } + return itemInMainHand; + } + + /** + * Retrieves the current off hand item from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return off hand item, or {@code null} if empty + */ + @Nullable + public ItemStack getOffHand(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getItemInOffHand(); + } + return itemInOffHand; + } + + /** + * Applies configuration from an {@link EntityEquipmentBuilder}. + * + * @param builder configured builder + * @param liveEntity optional active entity to update immediately + */ + public void applyBuilder(@NonNull EntityEquipmentBuilder builder, @Nullable LivingEntity liveEntity) { + if (builder.getHelmet() != null) setHelmet(builder.getHelmet(), liveEntity); + if (builder.getChestplate() != null) setChestplate(builder.getChestplate(), liveEntity); + if (builder.getLeggings() != null) setLeggings(builder.getLeggings(), liveEntity); + if (builder.getBoots() != null) setBoots(builder.getBoots(), liveEntity); + if (builder.getMainHand() != null) setMainHand(builder.getMainHand(), liveEntity); + if (builder.getOffHand() != null) setOffHand(builder.getOffHand(), liveEntity); + } + + /** + * Applies all configured equipment to the newly spawned or rehydrated entity and zeroes drop chances. + * + * @param entity target living entity + */ + public void applyTo(@NonNull LivingEntity entity) { + EntityEquipment equipment = entity.getEquipment(); + if (equipment == null) { + return; + } + + if (armor != null) { + equipment.setArmorContents(armor); + } + if (itemInMainHand != null) { + equipment.setItemInMainHand(itemInMainHand); + } + if (itemInOffHand != null) { + equipment.setItemInOffHand(itemInOffHand); + } + + zeroEquipmentDropChances(equipment); + } + + /** + * Ensures equipment items do not drop naturally when the entity dies. + * Custom drops are controlled through the loot system. + * + * @param equipment entity equipment to zero + */ + public static void zeroEquipmentDropChances(@NonNull EntityEquipment equipment) { + equipment.setHelmetDropChance(0f); + equipment.setChestplateDropChance(0f); + equipment.setLeggingsDropChance(0f); + equipment.setBootsDropChance(0f); + equipment.setItemInMainHandDropChance(0f); + equipment.setItemInOffHandDropChance(0f); + } + + /** + * Creates a deep copy of this equipment holder with cloned item stacks. + * + * @return cloned equipment holder + */ + public EntityEquipmentHolder copy() { + EntityEquipmentHolder copy = new EntityEquipmentHolder(); + if (this.armor != null) { + copy.armor = new ItemStack[4]; + for (int i = 0; i < 4; i++) { + copy.armor[i] = cloneItem(this.armor[i]); + } + } + copy.itemInMainHand = cloneItem(this.itemInMainHand); + copy.itemInOffHand = cloneItem(this.itemInOffHand); + return copy; + } + + private static ItemStack cloneItem(ItemStack item) { + return item != null ? item.clone() : null; + } + + private static boolean isEntityActive(LivingEntity entity) { + return entity != null && entity.isValid() && !entity.isDead(); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java index bbe25ea9..571fb10c 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java @@ -8,7 +8,6 @@ import fr.moussax.blightedSMP.engine.entities.defense.EntityImmunity; import fr.moussax.blightedSMP.engine.player.BlightedPlayer; import org.bukkit.Bukkit; -import org.bukkit.Chunk; import org.bukkit.Sound; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; @@ -24,12 +23,13 @@ import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Vector; -import java.util.Collection; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.*; +import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.FAST_PASS_TAG; +import static fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager.ATTACHMENT_OWNER_KEY; +import static fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager.ATTACHMENT_ROLE_KEY; /** * Event listener handling damage, potion effects, health updates, death, and chunk loading @@ -43,54 +43,6 @@ public BlightedEntitiesListener() { EntityManager.initialize(); } - /** - * Delegates entity registration to {@link EntityManager#registerEntity(LivingEntity, BlightedEntity)}. - * - * @param entity living entity to track - * @param blighted blighted entity wrapper instance - */ - public static void registerEntity(LivingEntity entity, BlightedEntity blighted) { - EntityManager.registerEntity(entity, blighted); - } - - /** - * Delegates entity unregistration to {@link EntityManager#unregisterEntity(LivingEntity)}. - * - * @param entity living entity to stop tracking - */ - public static void unregisterEntity(LivingEntity entity) { - EntityManager.unregisterEntity(entity); - } - - /** - * Delegates attachment registration to {@link EntityManager#registerAttachment(Entity, BlightedEntity)}. - * - * @param attachment attachment entity - * @param owner owning blighted entity wrapper - */ - public static void registerAttachment(Entity attachment, BlightedEntity owner) { - EntityManager.registerAttachment(attachment, owner); - } - - /** - * Delegates attachment unregistration to {@link EntityManager#unregisterAttachment(Entity)}. - * - * @param attachment attachment entity to unregister - */ - public static void unregisterAttachment(Entity attachment) { - EntityManager.unregisterAttachment(attachment); - } - - /** - * Delegates entity lookup to {@link EntityManager#getBlightedEntity(Entity)}. - * - * @param entity target entity - * @return blighted entity wrapper, or {@code null} if untracked - */ - public static BlightedEntity getBlightedEntity(Entity entity) { - return EntityManager.getBlightedEntity(entity); - } - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onEntityDamage(EntityDamageEvent event) { if (event instanceof EntityDamageByEntityEvent damageByEntity) { @@ -163,7 +115,7 @@ private void handleDamageDealt(EntityDamageByEntityEvent event) { ? shooter : rawDamager; - BlightedEntity damager = getBlightedEntity(source); + BlightedEntity damager = EntityManager.getBlightedEntity(source); if (damager != null) { damager.onDamageDealt(event); } @@ -207,8 +159,12 @@ private void handleBlightedEntityDamage(BlightedEntity blighted, LivingEntity en for (var component : blighted.getComponents()) { component.onDamageTaken(blighted, event); } - double remainingHealth = entity.getHealth() - event.getFinalDamage(); + if (event.isCancelled()) { + return; + } + + double remainingHealth = entity.getHealth() - event.getFinalDamage(); if (remainingHealth > 0) { Bukkit.getScheduler().runTaskLater(BlightedSMP.getInstance(), () -> { @@ -217,9 +173,7 @@ private void handleBlightedEntityDamage(BlightedEntity blighted, LivingEntity en blighted.evaluatePhases(entity.getHealth()); } }, 1L); - return; } - blighted.killAllAttachments(); } private void flashHurtAndCancelKnockback(BlightedEntity owner, Entity hitEntity) { @@ -333,15 +287,7 @@ public void onEntityDeath(EntityDeathEvent event) { @EventHandler public void onChunkLoad(ChunkLoadEvent event) { - Bukkit.getScheduler().runTaskLater(BlightedSMP.getInstance(), () -> rehydrateChunk(event.getChunk()), 1L); - } - - public static Collection getActiveEntities() { - return EntityManager.getActiveEntities(); - } - - public static void rehydrateChunk(Chunk chunk) { - EntityManager.rehydrateChunk(chunk); + Bukkit.getScheduler().runTaskLater(BlightedSMP.getInstance(), () -> EntityManager.rehydrateChunk(event.getChunk()), 1L); } private Player getPlayerDamager(Entity damager) { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java index b01f6f17..90f44796 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java @@ -9,7 +9,7 @@ import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageByEntityEvent; -import static fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener.getBlightedEntity; +import static fr.moussax.blightedSMP.engine.entities.EntityManager.getBlightedEntity; public final class EntityComponentListener implements Listener { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java index 065ed9ef..5eadcd71 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.engine.entities.listeners; -import fr.moussax.blightedSMP.engine.entities.registry.SpawnableEntitiesRegistry; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnEvaluator; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; @@ -43,7 +43,7 @@ public void onCreatureSpawn(CreatureSpawnEvent event) { SpawnableEntity selected = SpawnEvaluator.selectCandidate(candidates, location, world, ThreadLocalRandom.current()); if (selected != null) { event.setCancelled(true); - selected.clone().spawn(location); + selected.createInstance().spawn(location); } } @@ -54,10 +54,10 @@ public synchronized void rebuildCache() { if (!cacheDirty) return; Map> newCache = new EnumMap<>(EntityType.class); - for (SpawnableEntity entity : SpawnableEntitiesRegistry.getAll()) { + for (SpawnableEntity entity : EntitiesRegistry.getSpawnables()) { SpawnMode mode = entity.getSpawnMode(); if (mode == SpawnMode.REPLACEMENT || mode == SpawnMode.HYBRID) { - newCache.computeIfAbsent(entity.getEntityType(), k -> new ArrayList<>()).add(entity); + newCache.computeIfAbsent(entity.getEntityType(), entityType -> new ArrayList<>()).add(entity); } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java new file mode 100644 index 00000000..bc84db6b --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java @@ -0,0 +1,199 @@ +package fr.moussax.blightedSMP.engine.entities.phases; + +import fr.moussax.bedrock.utils.debug.Log; +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import org.jspecify.annotations.NonNull; + +import java.util.*; +import java.util.function.DoubleToLongFunction; + +/** + * Manages health-based phase transitions, core abilities, and phase-bound lifecycle tasks for an entity. + */ +public final class EntityPhaseManager { + + private final BlightedEntity owner; + private final DoubleToLongFunction transitionHandler; + private final NavigableMap phaseThresholds; + private LifecycleTaskManager coreTasks = new LifecycleTaskManager(); + private LifecycleTaskManager phaseTasks = new LifecycleTaskManager(); + private boolean isPhaseCallbackRunning; + + public EntityPhaseManager(@NonNull BlightedEntity owner, @NonNull DoubleToLongFunction transitionHandler) { + this(owner, transitionHandler, new TreeMap<>(Collections.reverseOrder())); + } + + private EntityPhaseManager( + @NonNull BlightedEntity owner, + @NonNull DoubleToLongFunction transitionHandler, + @NonNull NavigableMap thresholds + ) { + this.owner = Objects.requireNonNull(owner, "owner cannot be null"); + this.transitionHandler = Objects.requireNonNull(transitionHandler, "transitionHandler cannot be null"); + this.phaseThresholds = new TreeMap<>(thresholds); + } + + /** + * Registers a phase triggered at or below the given health percentage. + * + * @param healthPercentage threshold in the range {@code 0.0–1.0} + * @param onTransition action executed when the phase is triggered + */ + public void registerPhase(double healthPercentage, @NonNull Runnable onTransition) { + phaseThresholds.put(healthPercentage, Objects.requireNonNull(onTransition, "onTransition cannot be null")); + } + + /** + * Evaluates registered phases against the entity's current health. + * + * @param currentHealth current health value + */ + public void evaluatePhases(double currentHealth) { + if (phaseThresholds.isEmpty()) { + return; + } + + double maxHealth = Math.max(1, owner.getMaxHealth()); + double healthPercentage = currentHealth / maxHealth; + + while (!phaseThresholds.isEmpty() && healthPercentage <= phaseThresholds.firstKey()) { + Map.Entry entry = phaseThresholds.pollFirstEntry(); + if (entry == null) { + break; + } + + phaseTasks.cancelAll(); + phaseTasks = new LifecycleTaskManager(); + isPhaseCallbackRunning = true; + try { + entry.getValue().run(); + } finally { + isPhaseCallbackRunning = false; + } + long transitionDuration = transitionHandler.applyAsLong(entry.getKey()); + + if (transitionDuration > 0) { + owner.setPerformingAbility(true); + addCoreDelayedAction(transitionDuration, () -> { + owner.setPerformingAbility(false); + phaseTasks.scheduleAll(); + }); + } else { + phaseTasks.scheduleAll(); + } + } + } + + /** + * Registers a repeating task in the core lifecycle. + * + * @param delayTicks initial delay in server ticks + * @param periodTicks execution interval in server ticks + * @param action task action + */ + public void addCoreAbility(long delayTicks, long periodTicks, @NonNull Runnable action) { + scheduleAbility(coreTasks, delayTicks, periodTicks, action); + } + + /** + * Registers a repeating task in the current phase lifecycle. + * + * @param delayTicks initial delay in server ticks + * @param periodTicks execution interval in server ticks + * @param action task action + */ + public void addPhaseAbility(long delayTicks, long periodTicks, @NonNull Runnable action) { + scheduleAbility(phaseTasks, delayTicks, periodTicks, action); + } + + /** + * Registers a delayed task in the core lifecycle. + * + * @param delayTicks delay in server ticks + * @param action task action + */ + public void addCoreDelayedAction(long delayTicks, @NonNull Runnable action) { + scheduleDelayedAction(coreTasks, delayTicks, action); + } + + /** + * Registers a delayed task in the current phase lifecycle. + * + * @param delayTicks delay in server ticks + * @param action task action + */ + public void addPhaseDelayedAction(long delayTicks, @NonNull Runnable action) { + scheduleDelayedAction(phaseTasks, delayTicks, action); + } + + /** + * Schedules all core and phase tasks with the server scheduler. + */ + public void scheduleAllTasks() { + coreTasks.scheduleAll(); + phaseTasks.scheduleAll(); + } + + /** + * Schedules all core tasks with the server scheduler. + */ + public void scheduleAllCoreTasks() { + coreTasks.scheduleAll(); + } + + /** + * Cancels all scheduled core and phase tasks. + */ + public void cancelAllTasks() { + coreTasks.cancelAll(); + phaseTasks.cancelAll(); + } + + private void scheduleAbility(LifecycleTaskManager manager, long delayTicks, long periodTicks, Runnable action) { + manager.addRepeatingTask(() -> { + if (!owner.isAlive()) { + return; + } + try { + action.run(); + } catch (Exception exception) { + Log.warn("EntityPhaseManager", "Ability threw an exception on entity '" + owner.getName() + "': " + exception.getMessage()); + } + }, delayTicks, periodTicks); + if (canScheduleTask() && (!isPhaseCallbackRunning || manager != phaseTasks)) { + manager.scheduleLast(); + } + } + + private void scheduleDelayedAction(LifecycleTaskManager manager, long delayTicks, Runnable action) { + manager.addDelayedTask(() -> { + if (!owner.isAlive()) { + return; + } + try { + action.run(); + } catch (Exception exception) { + Log.warn("EntityPhaseManager", "Delayed action threw an exception on entity '" + owner.getName() + "': " + exception.getMessage()); + } + }, delayTicks); + if (canScheduleTask() && (!isPhaseCallbackRunning || manager != phaseTasks)) { + manager.scheduleLast(); + } + } + + private boolean canScheduleTask() { + return owner.getEntity() != null && !owner.getEntity().isDead() && owner.isRuntimeInitialized(); + } + + /** + * Creates a detached copy of this phase manager bound to a new owner and transition handler. + * + * @param newOwner target entity instance + * @param newHandler transition callback + * @return detached phase manager + */ + @NonNull + public EntityPhaseManager copy(@NonNull BlightedEntity newOwner, @NonNull DoubleToLongFunction newHandler) { + return new EntityPhaseManager(newOwner, newHandler, this.phaseThresholds); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java new file mode 100644 index 00000000..dfd2882b --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java @@ -0,0 +1,30 @@ +package fr.moussax.blightedSMP.engine.entities.phases; + +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; + +/** + * Fluent builder for configuring mob health phase transitions. + */ +public final class EntityPhasesBuilder { + + private final BlightedEntity entity; + + public EntityPhasesBuilder(@NonNull BlightedEntity entity) { + this.entity = Objects.requireNonNull(entity, "entity cannot be null"); + } + + /** + * Registers an action executed when the entity's health percentage drops to or below the threshold. + * + * @param healthThreshold health percentage in range [0.0, 1.0] (e.g. 0.50 for 50% HP) + * @param action action to execute + * @return this builder + */ + public EntityPhasesBuilder at(double healthThreshold, @NonNull Runnable action) { + entity.getPhaseManager().registerPhase(healthThreshold, action); + return this; + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/LifecycleTaskManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/LifecycleTaskManager.java similarity index 52% rename from blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/LifecycleTaskManager.java rename to blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/LifecycleTaskManager.java index 234a6f42..8e541a5e 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/LifecycleTaskManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/LifecycleTaskManager.java @@ -1,16 +1,15 @@ -package fr.moussax.blightedSMP.engine.entities; +package fr.moussax.blightedSMP.engine.entities.phases; import fr.moussax.blightedSMP.BlightedSMP; import org.bukkit.Bukkit; -import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; +import java.util.Objects; /** - * Manages scheduler tasks bound to the lifecycle of a {@link BlightedEntity}. + * Manages scheduler tasks bound to the lifecycle of a {@link fr.moussax.blightedSMP.engine.entities.BlightedEntity}. *

* Allows entities to register delayed or repeating tasks that are automatically * scheduled on initialization and canceled on destruction. @@ -28,7 +27,7 @@ public final class LifecycleTaskManager { */ public void addRepeatingTask(Runnable action, long delayTicks, long periodTicks) { ensureList(); - tasks.add(new ScheduledTask(action, null, delayTicks, periodTicks, true)); + tasks.add(new ScheduledTask(Objects.requireNonNull(action, "action cannot be null"), delayTicks, periodTicks, true)); } /** @@ -39,30 +38,7 @@ public void addRepeatingTask(Runnable action, long delayTicks, long periodTicks) */ public void addDelayedTask(Runnable action, long delayTicks) { ensureList(); - tasks.add(new ScheduledTask(action, null, delayTicks, 0L, false)); - } - - /** - * Adds a repeating task using a {@link BukkitRunnable} supplier (for backwards compatibility). - * - * @param factory supplier creating a new {@link BukkitRunnable} instance - * @param delayTicks initial delay before the first execution, in ticks - * @param periodTicks interval between consecutive runs, in ticks - */ - public void addRepeatingTask(Supplier factory, long delayTicks, long periodTicks) { - ensureList(); - tasks.add(new ScheduledTask(null, factory, delayTicks, periodTicks, true)); - } - - /** - * Adds a delayed task using a {@link BukkitRunnable} supplier (for backwards compatibility). - * - * @param factory supplier creating a new {@link BukkitRunnable} instance - * @param delayTicks delay before execution, in ticks - */ - public void addDelayedTask(Supplier factory, long delayTicks) { - ensureList(); - tasks.add(new ScheduledTask(null, factory, delayTicks, 0L, false)); + tasks.add(new ScheduledTask(Objects.requireNonNull(action, "action cannot be null"), delayTicks, 0L, false)); } /** @@ -106,21 +82,13 @@ private void onTaskComplete(ScheduledTask task) { private static final class ScheduledTask { private final Runnable action; - private final Supplier factory; private final long delayTicks; private final long periodTicks; private final boolean repeating; private BukkitTask currentTask; - private BukkitRunnable currentRunnable; - - private ScheduledTask( - Runnable action, - Supplier factory, - long delayTicks, - long periodTicks, - boolean repeating) { + + private ScheduledTask(Runnable action, long delayTicks, long periodTicks, boolean repeating) { this.action = action; - this.factory = factory; this.delayTicks = delayTicks; this.periodTicks = periodTicks; this.repeating = repeating; @@ -132,29 +100,13 @@ private void schedule(LifecycleTaskManager manager) { var plugin = BlightedSMP.getInstance(); if (repeating) { - if (action != null) { - currentTask = Bukkit.getScheduler().runTaskTimer(plugin, action, delayTicks, periodTicks); - } else if (factory != null) { - currentRunnable = factory.get(); - if (currentRunnable != null) { - currentTask = currentRunnable.runTaskTimer(plugin, delayTicks, periodTicks); - } - } + currentTask = Bukkit.getScheduler().runTaskTimer(plugin, action, delayTicks, periodTicks); return; } - Runnable taskAction = action != null ? action : () -> { - if (factory != null) { - currentRunnable = factory.get(); - if (currentRunnable != null) { - currentRunnable.run(); - } - } - }; - currentTask = Bukkit.getScheduler().runTaskLater(plugin, () -> { try { - taskAction.run(); + action.run(); } finally { manager.onTaskComplete(ScheduledTask.this); } @@ -169,15 +121,7 @@ private void cancel() { } currentTask = null; } - if (currentRunnable != null) { - try { - if (!currentRunnable.isCancelled()) { - currentRunnable.cancel(); - } - } catch (Exception _) { - } - currentRunnable = null; - } } } } + diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java index e0e93b1c..2a07648b 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java @@ -5,32 +5,39 @@ import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.registry.EngineRegistry; import fr.moussax.blightedSMP.registry.RegistryModule; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import java.util.function.Supplier; /** - * Central registry for prototype {@link BlightedEntity} instances. + * Central registry for {@link BlightedEntity} definitions and instance factories. + * Instantiates pristine entities on demand using registered factories or zero-argument constructors. */ public final class EntitiesRegistry { private static final EngineRegistry REGISTRY = new EngineRegistry<>("EntitiesRegistry", BlightedEntity::getEntityId); + private static final Map> FACTORIES = new ConcurrentHashMap<>(); private static final List onRegisterCallbacks = new ArrayList<>(); private EntitiesRegistry() { } /** - * Adds callback executed when a prototype entity is registered. + * Adds callback executed when an entity is registered. * * @param callback callback runnable */ - public static void addOnRegisterCallback(Runnable callback) { - onRegisterCallbacks.add(callback); + public static void addOnRegisterCallback(@NonNull Runnable callback) { + onRegisterCallbacks.add(Objects.requireNonNull(callback, "callback cannot be null")); } /** @@ -44,76 +51,174 @@ public static void initialize(List>> mod } /** - * Registers entity prototype and executes registration callbacks. + * Registers an entity definition with a supplier factory. + * All subsequent creations will invoke this factory to produce pristine, unshared instances. + * + * @param factory entity factory + * @param entity type + */ + public static void register(@NonNull Supplier factory) { + Objects.requireNonNull(factory, "factory cannot be null"); + T sample = factory.get(); + if (sample == null) { + return; + } + REGISTRY.register(sample); + FACTORIES.put(sample.getEntityId(), factory); + notifyCallbacks(); + } + + /** + * Registers an entity prototype and binds an automatic zero-argument constructor factory. * * @param entity entity prototype to register */ - public static void register(BlightedEntity entity) { - if (entity == null) return; + public static void register(@Nullable BlightedEntity entity) { + if (entity == null) { + return; + } REGISTRY.register(entity); + registerDefaultFactory(entity); + notifyCallbacks(); + } - onRegisterCallbacks.forEach(callback -> { + /** + * Binds an explicit factory supplier for a specific entity ID. + * + * @param entityId target entity ID + * @param factory supplier factory + * @param entity type + */ + public static void registerFactory(@NonNull String entityId, @NonNull Supplier factory) { + FACTORIES.put( + Objects.requireNonNull(entityId, "entityId cannot be null"), + Objects.requireNonNull(factory, "factory cannot be null") + ); + } + + /** + * Creates and returns a fresh, independent instance of the registered entity + * ready to be spawned in the world. Prefers the registered factory supplier, + * falling back to zero-argument constructor reflection. + * + * @param entityId target entity identifier + * @param expected entity type + * @return fresh entity instance, or {@code null} if untracked + */ + @Nullable + @SuppressWarnings("unchecked") + public static T create(@Nullable String entityId) { + if (entityId == null) { + return null; + } + Supplier factory = FACTORIES.get(entityId); + if (factory != null) { try { - callback.run(); + return (T) factory.get(); } catch (Throwable throwable) { - Log.error("EntitiesRegistry", "Failed to execute onRegister callback: " + throwable.getMessage()); + Log.error("EntitiesRegistry", "Failed to create entity from factory '" + entityId + "': " + throwable.getMessage()); } - }); + } + return (T) instantiate(REGISTRY.get(entityId)); } /** - * Retrieves cloned instance of registered entity prototype by ID. + * Creates and returns a fresh, independent instance of the registered spawnable entity + * ready to be spawned in the world. * * @param entityId target entity identifier - * @return cloned entity instance, or {@code null} if untracked + * @return fresh spawnable instance, or {@code null} if untracked or not spawnable */ @Nullable - public static BlightedEntity get(String entityId) { - BlightedEntity prototype = REGISTRY.get(entityId); - return prototype != null ? prototype.clone() : null; + public static SpawnableEntity createSpawnable(@Nullable String entityId) { + BlightedEntity entity = create(entityId); + if (entity instanceof SpawnableEntity spawnable) { + return spawnable; + } + return null; } /** - * Retrieves a cloned instance of a registered spawnable entity prototype by ID. + * Retrieves the registered definition template for read-only inspection. + * Must not be spawned directly into the world. * * @param entityId target entity identifier - * @return cloned spawnable entity instance, or {@code null} if untracked or not spawnable + * @return registered definition template, or {@code null} if untracked */ @Nullable - public static SpawnableEntity getSpawnable(String entityId) { - BlightedEntity entity = REGISTRY.get(entityId); - if (!(entity instanceof SpawnableEntity spawnable)) { - return null; - } - return spawnable.clone(); + public static BlightedEntity getPrototype(@Nullable String entityId) { + return entityId != null ? REGISTRY.get(entityId) : null; } /** - * Returns a list of cloned instances of all registered entity prototypes. + * Returns an unmodifiable list of all registered entity definitions. * - * @return list of cloned entity prototypes + * @return unmodifiable list of registered entity definitions */ + @NonNull public static List getAll() { - return REGISTRY.getAll().stream().map(BlightedEntity::clone).toList(); + return List.copyOf(REGISTRY.getAll()); } /** - * Returns a list of cloned instances of all registered spawnable entity prototypes. + * Returns an unmodifiable list of all registered spawnable entity definitions. * - * @return list of cloned spawnable entity prototypes + * @return unmodifiable list of registered spawnable entity definitions */ + @NonNull public static List getSpawnables() { return REGISTRY.getAll().stream() .filter(SpawnableEntity.class::isInstance) - .map(entity -> ((SpawnableEntity) entity).clone()) + .map(SpawnableEntity.class::cast) .toList(); } /** - * Clears all registered entity prototypes and registration callbacks. + * Clears all registered entity prototypes, factories, and registration callbacks. */ public static void clear() { REGISTRY.clear(); + FACTORIES.clear(); onRegisterCallbacks.clear(); } + + private static BlightedEntity instantiate(@Nullable BlightedEntity prototype) { + if (prototype == null) { + return null; + } + Class clazz = prototype.getClass(); + try { + var constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Throwable throwable) { + Log.error("EntitiesRegistry", "Failed to instantiate entity '" + prototype.getEntityId() + + "' (" + clazz.getName() + "). Ensure a public zero-arg constructor exists."); + return null; + } + } + + private static void registerDefaultFactory(@NonNull BlightedEntity entity) { + Class clazz = entity.getClass(); + FACTORIES.put(entity.getEntityId(), () -> { + try { + var constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Throwable throwable) { + throw new IllegalStateException("Failed to instantiate entity '" + entity.getEntityId() + + "' (" + clazz.getName() + "). Ensure a public zero-arg constructor exists or register a factory.", throwable); + } + }); + } + + private static void notifyCallbacks() { + onRegisterCallbacks.forEach(callback -> { + try { + callback.run(); + } catch (Throwable throwable) { + Log.error("EntitiesRegistry", "Failed to execute onRegister callback: " + throwable.getMessage()); + } + }); + } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java deleted file mode 100644 index 7b53859a..00000000 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java +++ /dev/null @@ -1,51 +0,0 @@ -package fr.moussax.blightedSMP.engine.entities.registry; - -import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; -import org.jspecify.annotations.Nullable; - -import java.util.List; - -/** - * Convenience view delegating spawnable entity queries to {@link EntitiesRegistry}. - */ -public final class SpawnableEntitiesRegistry { - - private SpawnableEntitiesRegistry() { - } - - /** - * Retrieves a cloned instance of a registered spawnable entity prototype by ID. - * - * @param entityId target entity identifier - * @return cloned spawnable entity instance, or {@code null} if untracked or not spawnable - */ - @Nullable - public static SpawnableEntity get(String entityId) { - return EntitiesRegistry.getSpawnable(entityId); - } - - /** - * Returns a list of cloned instances of all registered spawnable entity prototypes. - * - * @return list of cloned spawnable entity prototypes - */ - public static List getAll() { - return EntitiesRegistry.getSpawnables(); - } - - /** - * Returns total number of registered spawnable entity prototypes. - * - * @return registered spawnable entity count - */ - public static int count() { - return EntitiesRegistry.getSpawnables().size(); - } - - /** - * No-op delegate for compatibility; delegate clearing is handled by {@link EntitiesRegistry#clear()}. - */ - public static void clear() { - // No-op: delegate handled by EntitiesRegistry.clear() - } -} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java index 28ab4e4d..fbfa9eef 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java @@ -5,6 +5,8 @@ import fr.moussax.blightedSMP.content.sound.BlightedSounds; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; +import fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import lombok.Getter; import lombok.Setter; import org.bukkit.Bukkit; @@ -20,6 +22,9 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +import java.time.Duration; +import java.util.Objects; + /** * Base class for ancient creatures summoned through an {@link AncientRitual}. * @@ -34,14 +39,19 @@ public abstract class AncientCreature extends BlightedEntity { private static final int DEFAULT_TIME_ALLOWANCE_SECONDS = 240; private static final double HOLOGRAM_RESCAN_RADIUS = 8.0; - private static final NamespacedKey HOLOGRAM_KEY = - new NamespacedKey(BlightedSMP.getInstance(), "ancient_creature_hologram"); + private static NamespacedKey hologramKey; + + private static NamespacedKey getHologramKey() { + if (hologramKey == null) { + hologramKey = new NamespacedKey(BlightedSMP.getInstance(), "ancient_creature_hologram"); + } + return hologramKey; + } @Getter - @Setter - protected int timeAllowance; + protected int timeAllowance = DEFAULT_TIME_ALLOWANCE_SECONDS; @Getter - protected int remainingSeconds; + protected int remainingSeconds = DEFAULT_TIME_ALLOWANCE_SECONDS; @Getter @Setter protected String summonerName = "Unknown"; @@ -53,114 +63,89 @@ public abstract class AncientCreature extends BlightedEntity { private boolean isCollapsing = false; /** - * Creates an ancient creature using the default time allowance. - * - * @param name creature display name - * @param maxHealth creature maximum health - * @param entityType Bukkit entity type - */ - public AncientCreature(@NonNull String name, int maxHealth, EntityType entityType) { - this(name, maxHealth, 1, 0, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS); - } - - /** - * Creates an ancient creature using the default time allowance. + * Creates an ancient creature with identity requirements using the default time allowance. * + * @param entityId unique entity identifier * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage * @param entityType Bukkit entity type */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, EntityType entityType) { - this(name, maxHealth, damage, 0, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS); + public AncientCreature(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + super(entityId, name, entityType); + boss(); } /** - * Creates an ancient creature using the default time allowance. + * Sets the countdown time allowance to defeat this ancient creature. * - * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param defense base armor value - * @param entityType Bukkit entity type + * @param duration time allowance + * @return this creature */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType) { - this(name, maxHealth, damage, defense, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS); - } - - /** - * Creates an ancient creature using the specified time allowance. - * - * @param name creature display name - * @param maxHealth creature maximum health - * @param entityType Bukkit entity type - * @param timeAllowanceSeconds time allowed to defeat the creature, in seconds - */ - public AncientCreature(@NonNull String name, int maxHealth, EntityType entityType, int timeAllowanceSeconds) { - this(name, maxHealth, 1, 0, entityType, timeAllowanceSeconds); + public AncientCreature timeAllowance(@NonNull Duration duration) { + Objects.requireNonNull(duration, "duration cannot be null"); + if (duration.toSeconds() <= 0) { + throw new IllegalArgumentException("timeAllowance must be at least 1 second, got: " + duration); + } + this.timeAllowance = (int) duration.toSeconds(); + this.remainingSeconds = this.timeAllowance; + return this; } /** - * Creates an ancient creature using the specified damage and time allowance. + * Sets the countdown time allowance in seconds to defeat this ancient creature. * - * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param entityType Bukkit entity type - * @param timeAllowanceSeconds time allowed to defeat the creature, in seconds + * @param seconds time allowance in seconds + * @return this creature */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, EntityType entityType, int timeAllowanceSeconds) { - this(name, maxHealth, damage, 0, entityType, timeAllowanceSeconds); + public AncientCreature timeAllowance(int seconds) { + if (seconds <= 0) { + throw new IllegalArgumentException("timeAllowance must be positive, got: " + seconds); + } + return timeAllowance(Duration.ofSeconds(seconds)); } /** - * Creates an ancient creature using the specified combat stats and time allowance. + * Sets the player who summoned this creature. * - * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param defense base armor value - * @param entityType Bukkit entity type - * @param timeAllowanceSeconds time allowed to defeat the creature, in seconds + * @param player summoning player, or {@code null} for unknown + * @return this creature */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType, int timeAllowanceSeconds) { - super(name, maxHealth, damage, defense, entityType); - this.timeAllowance = timeAllowanceSeconds; - this.remainingSeconds = timeAllowanceSeconds; - setBoss(true); + public AncientCreature summoner(@Nullable Player player) { + this.summonerName = (player != null) ? player.getName() : "Unknown"; + updateHologramText(); + return this; } /** - * Sets the player who summoned this creature. + * Sets the summoner name directly. * - *

The player's name is stored for display in the creature's hologram. - * If no player is supplied, the summoner is set to {@code "Unknown"}.

- * - * @param player summoning player, or {@code null} + * @param summonerName summoner name + * @return this creature */ - public void setSummoner(@Nullable Player player) { - this.summonerName = (player != null) ? player.getName() : "Unknown"; + public AncientCreature summoner(@NonNull String summonerName) { + this.summonerName = Objects.requireNonNull(summonerName, "summonerName cannot be null"); + updateHologramText(); + return this; } /** - * Sets the name of the ability currently active on this creature. - * - *

The attached hologram is updated immediately after the ability name - * changes.

+ * Sets the name of the ability currently active on this creature and updates the hologram. * * @param abilityName active ability name, or {@code null} when no ability is active + * @return this creature */ - public void setActiveAbilityName(@Nullable String abilityName) { + public AncientCreature activeAbility(@Nullable String abilityName) { this.activeAbilityName = abilityName; updateHologramText(); + return this; } /** * Clears the currently active ability and updates the hologram. + * + * @return this creature */ - public void clearActiveAbility() { - this.activeAbilityName = null; - updateHologramText(); + public AncientCreature clearActiveAbility() { + return activeAbility(null); } /** @@ -192,10 +177,10 @@ protected void onRehydrate(LivingEntity existing) { if (!(nearby instanceof TextDisplay display)) continue; PersistentDataContainer persistentDataContainer = display.getPersistentDataContainer(); - String attachedOwner = persistentDataContainer.get(ATTACHMENT_OWNER_KEY, PersistentDataType.STRING); + String attachedOwner = persistentDataContainer.get(EntityAttachmentManager.ATTACHMENT_OWNER_KEY, PersistentDataType.STRING); if (!ownerUuid.equals(attachedOwner)) continue; - if (persistentDataContainer.has(HOLOGRAM_KEY, PersistentDataType.BYTE)) { + if (persistentDataContainer.has(getHologramKey(), PersistentDataType.BYTE)) { this.hologram = display; break; } @@ -231,7 +216,7 @@ private void createHologram(Location spawnLocation) { ); display.setTransformation(transformation); - display.getPersistentDataContainer().set(HOLOGRAM_KEY, PersistentDataType.BYTE, (byte) 1); + display.getPersistentDataContainer().set(getHologramKey(), PersistentDataType.BYTE, (byte) 1); this.hologram = display; this.hologram.setText(buildHologramContent()); @@ -294,4 +279,14 @@ public void onDeath(Location location) { super.onDeath(location); BlightedSounds.ANCIENT_MOB_DEFEAT.play(location); } + + @NonNull + @Override + public AncientCreature createInstance() { + BlightedEntity fresh = EntitiesRegistry.create(getEntityId()); + if (fresh instanceof AncientCreature ancient) { + return ancient; + } + return (AncientCreature) super.createInstance(); + } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java index 4821a16e..25b32711 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java @@ -8,8 +8,6 @@ import fr.moussax.blightedSMP.engine.entities.rituals.registry.RitualRegistry; import fr.moussax.blightedSMP.engine.items.recipes.CraftingObject; import fr.moussax.blightedSMP.utils.Utilities; -import org.bukkit.ChatColor; -import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java index 9995c710..0d487c94 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java @@ -17,7 +17,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; -import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -146,7 +145,7 @@ private void checkRequirements(Player player) { } boolean hasItems = requiredCounts.entrySet().stream() - .allMatch(e -> inventoryCounts.getOrDefault(e.getKey(), 0) >= e.getValue()); + .allMatch(entry -> inventoryCounts.getOrDefault(entry.getKey(), 0) >= entry.getValue()); boolean hasGems = blightedPlayer.hasGems(ritual.getGemsCost()); boolean hasXp = player.getLevel() >= ritual.getLevelCost(); @@ -314,8 +313,8 @@ private void handleFinalImpact(Location location) { Bukkit.broadcastMessage("§5 ☤ §dThe §4" + ritual.getSummonedCreature().getName() + " §dhas been summoned by §f" + player.getName() + "§d."); - AncientCreature creature = (AncientCreature) ritual.getSummonedCreature().clone(); - creature.setSummoner(player); + AncientCreature creature = ritual.getSummonedCreature().createInstance(); + creature.summoner(player); creature.spawn(location); } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java index 28002b92..fbf6a11b 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java @@ -1,12 +1,12 @@ package fr.moussax.blightedSMP.engine.entities.spawnable; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; -import lombok.NoArgsConstructor; import org.bukkit.Location; import org.bukkit.World; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -15,22 +15,22 @@ *

All registered conditions must be satisfied for spawning to be allowed. * Evaluation fails fast on the first failing condition.

*/ -@NoArgsConstructor -public final class SpawnProfile { +public record SpawnProfile(List conditions) { - private final List conditions = new ArrayList<>(); - - private SpawnProfile(List conditions) { - this.conditions.addAll(conditions); + /** + * Constructs a spawn profile with an immutable snapshot of conditions. + * + * @param conditions conditions to evaluate + */ + public SpawnProfile(@NonNull List conditions) { + this.conditions = List.copyOf(conditions); } /** - * Adds a spawn condition rule to this profile. - * - * @param condition condition rule to add + * Constructs an empty spawn profile permitting spawning everywhere. */ - public void addCondition(@NonNull SpawnCondition condition) { - conditions.add(condition); + public SpawnProfile() { + this(Collections.emptyList()); } /** @@ -40,19 +40,26 @@ public void addCondition(@NonNull SpawnCondition condition) { * @param world target spawn world * @return {@code true} if all conditions pass, {@code false} if any condition fails */ - public boolean canSpawn(Location location, World world) { + public boolean canSpawn(@Nullable Location location, @Nullable World world) { + if (location == null || world == null) { + return false; + } for (SpawnCondition condition : conditions) { - if (!condition.testCanSpawnAt(location, world)) return false; + if (!condition.testCanSpawnAt(location, world)) { + return false; + } } return true; } /** - * Creates an independent copy of this spawn profile and its conditions. + * Returns an unmodifiable snapshot of registered spawn conditions. * - * @return a new spawn profile instance with identical conditions + * @return list of spawn conditions */ - public SpawnProfile copy() { - return new SpawnProfile(this.conditions); + @Override + @NonNull + public List conditions() { + return conditions; } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java index d9fd0ae2..713bc855 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java @@ -4,7 +4,7 @@ import fr.moussax.blightedSMP.engine.entities.BlightedEntity; import fr.moussax.blightedSMP.engine.entities.components.AffixRegistry; import fr.moussax.blightedSMP.engine.entities.components.EntityComponent; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; import lombok.Getter; import org.bukkit.Location; @@ -14,31 +14,39 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; import org.bukkit.persistence.PersistentDataType; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; /** * Abstract base class for custom entities capable of spawning naturally in the world. * *

Extends {@link BlightedEntity} with spawn probabilities, spawn evaluation rules - * ({@link SpawnProfile}), spawn modes ({@link SpawnMode}), and optional elite affix rolling.

+ * ({@link SpawnProfile}), spawn modes ({@link SpawnMode}), and elite affix mechanics.

*/ public abstract class SpawnableEntity extends BlightedEntity { - /** - * Persistent data key storing comma-separated active affix IDs assigned to this entity. - */ - public static final NamespacedKey AFFIXES_KEY = - new NamespacedKey(BlightedSMP.getInstance(), "blighted_active_affix"); + private static NamespacedKey affixesKey; - @Getter - private final double spawnProbability; + public static NamespacedKey getAffixesKey() { + if (affixesKey == null) { + affixesKey = new NamespacedKey(BlightedSMP.getInstance(), "blighted_active_affix"); + } + return affixesKey; + } + + public static final double DEFAULT_SPAWN_PROBABILITY = SpawningBuilder.DEFAULT_PROBABILITY; + public static final SpawnMode DEFAULT_SPAWN_MODE = SpawningBuilder.DEFAULT_MODE; @Getter - private final SpawnMode spawnMode; + private double spawnProbability = DEFAULT_SPAWN_PROBABILITY; - private SpawnProfile spawnProfile; + @Getter + private SpawnMode spawnMode = DEFAULT_SPAWN_MODE; @Getter private double affixChance = 0.0; @@ -46,118 +54,78 @@ public abstract class SpawnableEntity extends BlightedEntity { @Getter private int maxAffixes = 1; - /** - * Sets the chance for this entity to spawn with random elite affixes. - * - * @param affixChance probability in the range {@code [0.0, 1.0]} - * @throws IllegalArgumentException if {@code affixChance} is outside {@code [0.0, 1.0]} - */ - public void setAffixChance(double affixChance) { - if (affixChance < 0.0 || affixChance > 1.0) { - throw new IllegalArgumentException("affixChance must be in [0.0, 1.0], got: " + affixChance); - } - this.affixChance = affixChance; - } + private boolean eliteAuraStarted = false; + + @Getter + private SpawnProfile spawnProfile = new SpawnProfile(); /** - * Sets the maximum number of elite affixes this entity can roll when spawned. + * Constructs a spawnable entity with identity requirements. + * The default spawn probability is 0.01 in REPLACEMENT mode. * - * @param maxAffixes maximum affix count + * @param entityId unique entity identifier + * @param name display name + * @param entityType underlying a Minecraft entity type */ - public void setMaxAffixes(int maxAffixes) { - this.maxAffixes = Math.max(1, maxAffixes); + protected SpawnableEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + super(entityId, name, entityType); } /** - * Constructs a spawnable entity with default damage and defense attributes in replacement mode. + * Configures spawning rules, probability, mode, and affixes using a fluent builder consumer. * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} + * @param consumer action configuring the spawning builder */ - protected SpawnableEntity(String entityId, String name, int maxHealth, EntityType entityType, double probability) { - this(entityId, name, maxHealth, 1, 0, entityType, probability, SpawnMode.REPLACEMENT); + public void spawning(@NonNull Consumer consumer) { + Objects.requireNonNull(consumer, "consumer cannot be null"); + SpawningBuilder builder = new SpawningBuilder(); + builder.probability(this.spawnProbability); + builder.mode(this.spawnMode); + builder.affixes(this.affixChance, this.maxAffixes); + consumer.accept(builder); + this.spawnProbability = builder.getProbability(); + this.spawnMode = builder.getMode(); + this.affixChance = builder.getAffixChance(); + this.maxAffixes = builder.getMaxAffixes(); + this.spawnProfile = builder.buildProfile(); } /** - * Constructs a spawnable entity with default damage and defense attributes in the specified spawn mode. + * Sets elite affix rolling chance. * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - * @param mode spawn mode + * @param affixChance probability in [0.0, 1.0] + * @return this entity */ - protected SpawnableEntity( - String entityId, String name, int maxHealth, EntityType entityType, double probability, SpawnMode mode) { - this(entityId, name, maxHealth, 1, 0, entityType, probability, mode); + public SpawnableEntity affixChance(double affixChance) { + if (affixChance < 0.0 || affixChance > 1.0) { + throw new IllegalArgumentException("affixChance must be in [0.0, 1.0], got: " + affixChance); + } + this.affixChance = affixChance; + return this; } /** - * Constructs a spawnable entity with custom attack damage and zero defense in the specified spawn mode. + * Sets maximum number of rollable elite affixes. * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param damage base attack damage - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - * @param mode spawn mode + * @param maxAffixes maximum affixes + * @return this entity */ - protected SpawnableEntity( - String entityId, - String name, - int maxHealth, - int damage, - EntityType entityType, - double probability, - SpawnMode mode) { - this(entityId, name, maxHealth, damage, 0, entityType, probability, mode); + public SpawnableEntity maxAffixes(int maxAffixes) { + this.maxAffixes = Math.max(1, maxAffixes); + return this; } /** - * Constructs a spawnable entity with full attribute specifications and spawn profile initialization. + * Evaluates whether this entity can spawn at the specified location and world. * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param damage base attack damage - * @param defense base armor defense - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - * @param mode spawn mode - * @throws IllegalArgumentException if {@code probability} is outside {@code [0.0, 1.0]} + * @param location location to evaluate + * @param world world to evaluate + * @return {@code true} if all spawn profile conditions are satisfied, {@code false} otherwise */ - protected SpawnableEntity( - String entityId, - String name, - int maxHealth, - int damage, - int defense, - EntityType entityType, - double probability, - SpawnMode mode) { - super(name, maxHealth, damage, defense, entityType); - if (probability < 0.0 || probability > 1.0) { - throw new IllegalArgumentException("spawnProbability must be in [0.0, 1.0], got: " + probability); - } - - this.entityId = entityId; - this.spawnProbability = probability; - this.spawnMode = mode; - this.spawnProfile = new SpawnProfile(); - defineSpawnConditions(); + public boolean canSpawnAt(@Nullable Location location, @Nullable World world) { + return spawnProfile.canSpawn(location, world); } - /** - * Spawns this entity at the specified location and rolls for elite affix assignment. - * - * @param location target spawn location - * @return spawned living entity - */ @Override public LivingEntity spawn(Location location) { LivingEntity spawned = super.spawn(location); @@ -172,23 +140,18 @@ public LivingEntity spawn(Location location) { } spawned.getPersistentDataContainer() - .set(AFFIXES_KEY, PersistentDataType.STRING, String.join(",", affixIds)); + .set(getAffixesKey(), PersistentDataType.STRING, String.join(",", affixIds)); } } return spawned; } - /** - * Rehydrates an existing entity state, restoring persistent affixes if present. - * - * @param existing existing living entity instance in the world - */ @Override protected void onRehydrate(LivingEntity existing) { super.onRehydrate(existing); - String persistentAffixes = existing.getPersistentDataContainer().get(AFFIXES_KEY, PersistentDataType.STRING); + String persistentAffixes = existing.getPersistentDataContainer().get(getAffixesKey(), PersistentDataType.STRING); if (persistentAffixes != null && !persistentAffixes.isEmpty()) { String[] affixIds = persistentAffixes.split(","); for (String affixId : affixIds) { @@ -202,8 +165,6 @@ protected void onRehydrate(LivingEntity existing) { } } - private boolean eliteAuraStarted = false; - private void applyAffix(EntityComponent affix) { addComponent(affix); if (!eliteAuraStarted) { @@ -233,40 +194,13 @@ private void startEliteAura() { }); } - /** - * Hook method implemented by subclasses to register environment spawn conditions. - */ - protected abstract void defineSpawnConditions(); - - /** - * Adds a spawn condition rule to this entity's spawn profile. - * - * @param condition spawn condition rule to add - */ - protected void addCondition(SpawnCondition condition) { - spawnProfile.addCondition(condition); - } - - /** - * Evaluates whether this entity can spawn at the specified location and world. - * - * @param location location to evaluate - * @param world world to evaluate - * @return {@code true} if all spawn profile conditions are satisfied, {@code false} otherwise - */ - public boolean canSpawnAt(Location location, World world) { - return spawnProfile.canSpawn(location, world); - } - - /** - * Creates an independent clone of this spawnable entity definition. - * - * @return cloned entity definition - */ + @NonNull @Override - public SpawnableEntity clone() { - SpawnableEntity cloned = (SpawnableEntity) super.clone(); - cloned.spawnProfile = this.spawnProfile != null ? this.spawnProfile.copy() : new SpawnProfile(); - return cloned; + public SpawnableEntity createInstance() { + SpawnableEntity fresh = EntitiesRegistry.createSpawnable(getEntityId()); + if (fresh != null) { + return fresh; + } + return (SpawnableEntity) super.createInstance(); } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java new file mode 100644 index 00000000..bee32c1b --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java @@ -0,0 +1,265 @@ +package fr.moussax.blightedSMP.engine.entities.spawnable; + +import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; +import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; +import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; +import lombok.Getter; +import org.bukkit.World.Environment; +import org.bukkit.block.Biome; +import org.bukkit.generator.structure.Structure; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Fluent builder for configuring mob natural spawn rules, probability, and mode. + */ +@Getter +public final class SpawningBuilder { + + public static final double DEFAULT_PROBABILITY = 0.01; + public static final SpawnMode DEFAULT_MODE = SpawnMode.REPLACEMENT; + + private double probability = DEFAULT_PROBABILITY; + private SpawnMode mode = DEFAULT_MODE; + private double affixChance = 0.0; + private int maxAffixes = 1; + private final List conditions = new ArrayList<>(); + + /** + * Sets the spawn probability in range [0.0, 1.0]. + * + * @param probability spawn probability + * @return this builder + */ + public SpawningBuilder probability(double probability) { + if (probability < 0.0 || probability > 1.0) { + throw new IllegalArgumentException("probability must be in [0.0, 1.0], got: " + probability); + } + this.probability = probability; + return this; + } + + /** + * Sets the integration mode with Minecraft spawning mechanisms. + * + * @param mode spawn mode + * @return this builder + */ + public SpawningBuilder mode(@NonNull SpawnMode mode) { + this.mode = Objects.requireNonNull(mode, "mode cannot be null"); + return this; + } + + /** + * Sets elite affix roll chance and maximum rollable affixes. + * + * @param chance probability in [0.0, 1.0] + * @param maxAffixes maximum affixes + * @return this builder + */ + public SpawningBuilder affixes(double chance, int maxAffixes) { + if (chance < 0.0 || chance > 1.0) { + throw new IllegalArgumentException("affixChance must be in [0.0, 1.0], got: " + chance); + } + this.affixChance = chance; + this.maxAffixes = Math.max(1, maxAffixes); + return this; + } + + /** + * Sets elite affix roll chance with up to 1 rolled affix. + * + * @param chance probability in [0.0, 1.0] + * @return this builder + */ + public SpawningBuilder affixes(double chance) { + return affixes(chance, 1); + } + + /** + * Adds one or more spawn conditions evaluated with AND semantics. + * + * @param conditions conditions to enforce + * @return this builder + */ + public SpawningBuilder condition(@NonNull SpawnCondition... conditions) { + for (SpawnCondition condition : conditions) { + this.conditions.add(Objects.requireNonNull(condition, "condition cannot be null")); + } + return this; + } + + /** + * Convenience method adding a biome restriction rule. + * + * @param biomes allowed biomes + * @return this builder + */ + public SpawningBuilder biomes(@NonNull Biome... biomes) { + return condition(SpawnRules.biome(biomes)); + } + + /** + * Convenience method requiring the entity to spawn inside a structure. + * + * @param structure target structure + * @return this builder + */ + public SpawningBuilder insideStructure(@NonNull Structure structure) { + return condition(SpawnRules.insideStructure(structure)); + } + + /** + * Adds an OR condition group where at least one condition must pass. + * + * @param conditions conditions to evaluate + * @return this builder + */ + public SpawningBuilder anyOf(@NonNull SpawnCondition... conditions) { + return condition(SpawnCondition.anyOf(conditions)); + } + + /** + * Adds an AND condition group where all conditions must pass. + * + * @param conditions conditions to evaluate + * @return this builder + */ + public SpawningBuilder allOf(@NonNull SpawnCondition... conditions) { + return condition(SpawnCondition.allOf(conditions)); + } + + /** + * Adds a NOR condition group where none of the conditions may pass. + * + * @param conditions conditions that must not pass + * @return this builder + */ + public SpawningBuilder noneOf(@NonNull SpawnCondition... conditions) { + return condition(SpawnCondition.noneOf(conditions)); + } + + /** + * Adds an environment requirement. + * + * @param environment required world environment + * @return this builder + */ + public SpawningBuilder environment(@NonNull Environment environment) { + return condition(SpawnRules.environment(environment)); + } + + /** + * Adds an Overworld environment requirement. + * + * @return this builder + */ + public SpawningBuilder overworld() { + return condition(SpawnRules.overworld()); + } + + /** + * Adds a Nether environment requirement. + * + * @return this builder + */ + public SpawningBuilder nether() { + return condition(SpawnRules.nether()); + } + + /** + * Adds The End environment requirement. + * + * @return this builder + */ + public SpawningBuilder theEnd() { + return condition(SpawnRules.theEnd()); + } + + /** + * Adds standard Overworld hostile monster conditions (darkness, non-liquid). + * + * @return this builder + */ + public SpawningBuilder overworldHostile() { + return condition(SpawnRules.overworldHostile()); + } + + /** + * Adds surface-only Overworld hostile monster conditions (sky exposed, darkness, non-liquid). + * + * @return this builder + */ + public SpawningBuilder overworldSurfaceHostile() { + return condition(SpawnRules.overworldSurfaceHostile()); + } + + /** + * Adds Nether hostile monster conditions (non-liquid, light level <= 11). + * + * @return this builder + */ + public SpawningBuilder netherHostile() { + return condition(SpawnRules.netherHostile()); + } + + /** + * Adds a liquid requirement (water or lava). + * + * @return this builder + */ + public SpawningBuilder inLiquid() { + return condition(SpawnRules.inLiquid()); + } + + /** + * Adds a non-liquid requirement. + * + * @return this builder + */ + public SpawningBuilder notInLiquid() { + return condition(SpawnRules.notInLiquid()); + } + + /** + * Adds a requirement that the spawn location is exposed to the sky. + * + * @return this builder + */ + public SpawningBuilder skyExposed() { + return condition(SpawnRules.skyExposed()); + } + + /** + * Adds a maximum block light requirement. + * + * @param max maximum block light level + * @return this builder + */ + public SpawningBuilder maxBlockLight(int max) { + return condition(SpawnRules.maxBlockLight(max)); + } + + /** + * Adds a maximum total light level requirement. + * + * @param max maximum total light level + * @return this builder + */ + public SpawningBuilder maxLightLevel(int max) { + return condition(SpawnRules.maxLightLevel(max)); + } + + /** + * Builds an immutable {@link SpawnProfile} with the configured conditions. + * + * @return immutable spawn profile + */ + @NonNull + public SpawnProfile buildProfile() { + return new SpawnProfile(this.conditions); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java index 692c0689..f322cd5f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java @@ -49,4 +49,74 @@ default SpawnCondition or(SpawnCondition other) { default SpawnCondition negate() { return (location, world) -> !this.testCanSpawnAt(location, world); } + + /** + * Creates a condition requiring at least one of the provided conditions to pass (logical OR). + * + *

Short-circuits upon the first matching condition. If no conditions are supplied, + * this condition always fails.

+ * + * @param conditions conditions to evaluate + * @return composite OR condition + */ + static SpawnCondition anyOf(SpawnCondition... conditions) { + if (conditions.length == 0) { + return (_, _) -> false; + } + if (conditions.length == 1) { + return java.util.Objects.requireNonNull(conditions[0], "condition cannot be null"); + } + SpawnCondition[] copy = conditions.clone(); + for (SpawnCondition condition : copy) { + java.util.Objects.requireNonNull(condition, "condition cannot be null"); + } + return (location, world) -> { + for (SpawnCondition condition : copy) { + if (condition.testCanSpawnAt(location, world)) { + return true; + } + } + return false; + }; + } + + /** + * Creates a condition requiring all provided conditions to pass (logical AND). + * + *

Short-circuits upon the first failing condition. If no conditions are supplied, + * this condition always passes (vacuous truth).

+ * + * @param conditions conditions to evaluate + * @return composite AND condition + */ + static SpawnCondition allOf(SpawnCondition... conditions) { + if (conditions.length == 0) { + return (_, _) -> true; + } + if (conditions.length == 1) { + return java.util.Objects.requireNonNull(conditions[0], "condition cannot be null"); + } + SpawnCondition[] copy = conditions.clone(); + for (SpawnCondition condition : copy) { + java.util.Objects.requireNonNull(condition, "condition cannot be null"); + } + return (location, world) -> { + for (SpawnCondition condition : copy) { + if (!condition.testCanSpawnAt(location, world)) { + return false; + } + } + return true; + }; + } + + /** + * Creates a condition requiring none of the provided conditions to pass (logical NOR). + * + * @param conditions conditions that must not pass + * @return composite NOR condition + */ + static SpawnCondition noneOf(SpawnCondition... conditions) { + return anyOf(conditions).negate(); + } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java index 135bc790..1a5fddce 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java @@ -1,17 +1,15 @@ package fr.moussax.blightedSMP.engine.entities.spawnable.condition; -import org.bukkit.Material; -import org.bukkit.Raid; import org.bukkit.World; +import org.bukkit.World.Environment; import org.bukkit.block.Biome; -import org.bukkit.block.Block; -import org.bukkit.block.data.type.TrialSpawner; -import org.bukkit.block.data.type.TrialSpawner.State; import org.bukkit.generator.structure.GeneratedStructure; import org.bukkit.generator.structure.Structure; import org.bukkit.generator.structure.StructurePiece; +import org.jspecify.annotations.NonNull; import java.util.Collection; +import java.util.Objects; import java.util.Set; /** @@ -32,7 +30,7 @@ private SpawnRules() { * @param allowed biomes permitted for spawning * @return biome spawn condition predicate */ - public static SpawnCondition biome(Biome... allowed) { + public static SpawnCondition biome(@NonNull Biome... allowed) { Set biomeSet = Set.of(allowed); return (location, _) -> biomeSet.contains(location.getBlock().getBiome()); } @@ -43,28 +41,36 @@ public static SpawnCondition biome(Biome... allowed) { * @param environment target world environment * @return environment spawn condition predicate */ - public static SpawnCondition environment(World.Environment environment) { + public static SpawnCondition environment(@NonNull Environment environment) { + Objects.requireNonNull(environment, "environment cannot be null"); return (_, world) -> world.getEnvironment() == environment; } /** - * Creates a condition permitting spawning at or above a minimum Y coordinate. + * Creates a condition permitting spawning only in the Overworld environment. * - * @param minY minimum Y coordinate - * @return minimum height spawn condition predicate + * @return Overworld environment spawn condition predicate */ - public static SpawnCondition atLeastY(int minY) { - return (location, world) -> location.getBlockY() >= minY; + public static SpawnCondition overworld() { + return environment(World.Environment.NORMAL); } /** - * Creates a condition permitting spawning at or below a maximum Y coordinate. + * Creates a condition permitting spawning only in the Nether environment. * - * @param maxY maximum Y coordinate - * @return maximum height spawn condition predicate + * @return Nether environment spawn condition predicate */ - public static SpawnCondition atMostY(int maxY) { - return (location, _) -> location.getBlockY() <= maxY; + public static SpawnCondition nether() { + return environment(World.Environment.NETHER); + } + + /** + * Creates a condition permitting spawning only in The End environment. + * + * @return The End environment spawn condition predicate + */ + public static SpawnCondition theEnd() { + return environment(World.Environment.THE_END); } /** @@ -97,51 +103,21 @@ public static SpawnCondition skyExposed() { } /** - * Creates a condition permitting spawning only outside liquid blocks (water or lava). - * - * @return non-liquid spawn condition predicate - */ - public static SpawnCondition notInLiquid() { - return (location, world) -> !location.getBlock().isLiquid(); - } - - /** - * Creates a condition permitting spawning during nighttime hours (ticks 13000 to 23000). + * Creates a condition permitting spawning inside liquid blocks (water or lava). * - * @return nighttime spawn condition predicate + * @return liquid spawn condition predicate */ - public static SpawnCondition nightTime() { - return (_, world) -> { - long time = world.getTime(); - return time >= 13000 && time <= 23000; - }; + public static SpawnCondition inLiquid() { + return (location, _) -> location.getBlock().isLiquid(); } /** - * Creates a condition permitting spawning only during clear weather. - * - * @return clear sky spawn condition predicate - */ - public static SpawnCondition clearSky() { - return (location, world) -> !world.hasStorm(); - } - - /** - * Creates a condition permitting spawning during rain or stormy weather. - * - * @return rain spawn condition predicate - */ - public static SpawnCondition isRaining() { - return (location, world) -> world.hasStorm(); - } - - /** - * Creates a condition permitting spawning during thunder storms. + * Creates a condition permitting spawning only outside liquid blocks (water or lava). * - * @return thunder spawn condition predicate + * @return non-liquid spawn condition predicate */ - public static SpawnCondition isThundering() { - return (location, world) -> world.isThundering(); + public static SpawnCondition notInLiquid() { + return inLiquid().negate(); } /** @@ -150,7 +126,8 @@ public static SpawnCondition isThundering() { * @param structure world structure type * @return structure spawn condition predicate */ - public static SpawnCondition insideStructure(Structure structure) { + public static SpawnCondition insideStructure(@NonNull Structure structure) { + Objects.requireNonNull(structure, "structure cannot be null"); return (location, world) -> { int chunkX = location.getBlockX() >> 4; int chunkZ = location.getBlockZ() >> 4; @@ -168,97 +145,6 @@ public static SpawnCondition insideStructure(Structure structure) { }; } - /** - * Creates a condition permitting spawning inside Trial Chambers structures. - * - * @return trial chamber structure spawn condition predicate - */ - public static SpawnCondition insideTrialChamber() { - return insideStructure(Structure.TRIAL_CHAMBERS); - } - - /** - * Creates a condition permitting spawning near an active raid within a specified block radius. - * - * @param radius search radius in blocks - * @return active raid spawn condition predicate - */ - public static SpawnCondition nearActiveRaid(int radius) { - return (location, world) -> { - Raid nearest = world.locateNearestRaid(location, radius); - return nearest != null && nearest.isStarted(); - }; - } - - /** - * Creates a condition permitting spawning only when no active raid is within the specified radius. - * - * @param radius search radius in blocks - * @return no nearby raid spawn condition predicate - */ - public static SpawnCondition noNearbyRaid(int radius) { - return nearActiveRaid(radius).negate(); - } - - /** - * Creates a condition permitting spawning near an active Trial Spawner within a specified block radius. - * - * @param radius search radius in blocks - * @return active trial spawner spawn condition predicate - */ - public static SpawnCondition nearActiveTrialSpawner(int radius) { - return (location, world) -> { - int originX = location.getBlockX(); - int originY = location.getBlockY(); - int originZ = location.getBlockZ(); - - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { - for (int dz = -radius; dz <= radius; dz++) { - Block block = world.getBlockAt(originX + dx, originY + dy, originZ + dz); - if (block.getType() != Material.TRIAL_SPAWNER) continue; - - if (block.getBlockData() instanceof TrialSpawner trialData - && trialData.getTrialSpawnerState() == State.ACTIVE) { - return true; - } - } - } - } - return false; - }; - } - - /** - * Creates a condition permitting spawning near an active Ominous Trial Spawner within a specified block radius. - * - * @param radius search radius in blocks - * @return active ominous trial spawner spawn condition predicate - */ - public static SpawnCondition nearOminousTrialSpawner(int radius) { - return (location, world) -> { - int originX = location.getBlockX(); - int originY = location.getBlockY(); - int originZ = location.getBlockZ(); - - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { - for (int dz = -radius; dz <= radius; dz++) { - Block block = world.getBlockAt(originX + dx, originY + dy, originZ + dz); - if (block.getType() != Material.TRIAL_SPAWNER) continue; - - if (block.getBlockData() instanceof TrialSpawner trialData - && trialData.isOminous() - && trialData.getTrialSpawnerState() == State.ACTIVE) { - return true; - } - } - } - } - return false; - }; - } - /** * Composite condition for standard Overworld hostile monster spawning (darkness, non-liquid). * diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java index b5379013..f8e8d6a3 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java @@ -1,7 +1,7 @@ package fr.moussax.blightedSMP.engine.entities.spawnable.engine; import fr.moussax.blightedSMP.BlightedSMP; -import fr.moussax.blightedSMP.engine.entities.registry.SpawnableEntitiesRegistry; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import org.bukkit.Chunk; import org.bukkit.Location; @@ -79,7 +79,7 @@ public void run() { private void refreshCaches() { cachedIndependentEntities.clear(); - for (SpawnableEntity entity : SpawnableEntitiesRegistry.getAll()) { + for (SpawnableEntity entity : EntitiesRegistry.getSpawnables()) { SpawnMode mode = entity.getSpawnMode(); if (mode == SpawnMode.INDEPENDENT || mode == SpawnMode.HYBRID) { cachedIndependentEntities.add(entity); @@ -127,7 +127,7 @@ private void attemptSpawnInChunk(Chunk chunk, World world, ThreadLocalRandom ran SpawnableEntity selected = SpawnEvaluator.selectCandidate(cachedIndependentEntities, location, world, random); if (selected != null) { - selected.clone().spawn(location); + selected.createInstance().spawn(location); return; // Stop scanning column once an entity spawns } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java index 5e022d3e..299562ac 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java @@ -57,6 +57,6 @@ public static SpawnableEntity selectCandidate(List candidates, } } - return null; + return eligible.getLast(); } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java new file mode 100644 index 00000000..f60df4cd --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java @@ -0,0 +1,198 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Manages custom attribute configuration, modifier stripping, health clamping, + * and live entity attribute synchronization. + */ +public final class EntityAttributeManager { + + private final Map attributes; + + public EntityAttributeManager() { + this(new HashMap<>()); + } + + private EntityAttributeManager(@NonNull Map attributes) { + this.attributes = new HashMap<>(attributes); + } + + /** + * Sets an attribute base value. If the entity is currently active, + * updates the live Bukkit AttributeInstance immediately. + * + * @param attribute target attribute + * @param value base attribute value + * @param liveEntity live entity, or null if unspawned + */ + public void setAttribute(@NonNull Attribute attribute, double value, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(attribute, "attribute cannot be null"); + attributes.put(attribute, value); + if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + AttributeInstance instance = liveEntity.getAttribute(attribute); + if (instance != null) { + instance.setBaseValue(value); + } + } + } + + /** + * Returns the effective base value for an attribute, querying the live entity if active, + * or the configured attribute map if unspawned. + * + * @param attribute target attribute + * @param liveEntity live entity, or null if unspawned + * @return effective base value, or 0.0 if not configured + */ + public double getAttributeValue(@NonNull Attribute attribute, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(attribute, "attribute cannot be null"); + if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + AttributeInstance instance = liveEntity.getAttribute(attribute); + if (instance != null) { + return instance.getBaseValue(); + } + } + return attributes.getOrDefault(attribute, 0.0); + } + + /** + * Applies combat stats and all configured attributes to a live entity on initial spawn. + * Sets health to max health and locks persistence properties. + * + * @param liveEntity live entity + * @param maxHealth maximum health + * @param damage attack damage + * @param defense armor defense + * @param isBoss whether entity is a boss + */ + public void initialize( + @NonNull LivingEntity liveEntity, + int maxHealth, + int damage, + int defense, + boolean isBoss + ) { + applyBaseAttributes(liveEntity, maxHealth, damage, defense); + + AttributeInstance maxHealthAttribute = liveEntity.getAttribute(Attribute.MAX_HEALTH); + if (maxHealthAttribute != null) { + liveEntity.setHealth(maxHealthAttribute.getValue()); + } + + lockEntityProperties(liveEntity, isBoss); + } + + /** + * Rehydrates combat stats and attributes when binding to an existing world entity. + * Clamps health to current max health and locks persistence properties. + * + * @param liveEntity live entity + * @param maxHealth maximum health + * @param damage attack damage + * @param defense armor defense + * @param isBoss whether entity is a boss + */ + public void rehydrate( + @NonNull LivingEntity liveEntity, + int maxHealth, + int damage, + int defense, + boolean isBoss + ) { + applyBaseAttributes(liveEntity, maxHealth, damage, defense); + + AttributeInstance maxHealthAttribute = liveEntity.getAttribute(Attribute.MAX_HEALTH); + if (maxHealthAttribute != null) { + liveEntity.setHealth(Math.min(liveEntity.getHealth(), maxHealthAttribute.getValue())); + } + + lockEntityProperties(liveEntity, isBoss); + } + + /** + * Sets the live entity's current health, clamped between 0 and maximum health. + * + * @param liveEntity live entity + * @param health target health value + * @param fallbackMaxHealth fallback maximum health + */ + public void setHealth(@Nullable LivingEntity liveEntity, double health, double fallbackMaxHealth) { + if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + AttributeInstance maxHealthAttribute = liveEntity.getAttribute(Attribute.MAX_HEALTH); + double max = maxHealthAttribute != null ? maxHealthAttribute.getValue() : fallbackMaxHealth; + liveEntity.setHealth(Math.max(0.0, Math.min(health, max))); + } + } + + /** + * Returns the live entity's current health, or {@code 0.0} if unspawned or dead. + * + * @param liveEntity live entity + * @return current health + */ + public double getHealth(@Nullable LivingEntity liveEntity) { + return liveEntity != null && liveEntity.isValid() && !liveEntity.isDead() ? liveEntity.getHealth() : 0.0; + } + + /** + * Applies standard Minecraft persistence and despawn settings based on boss status. + * + * @param liveEntity target entity + * @param isBoss whether the entity is a boss + */ + public void lockEntityProperties(@NonNull LivingEntity liveEntity, boolean isBoss) { + liveEntity.setRemoveWhenFarAway(!isBoss); + liveEntity.setPersistent(isBoss); + liveEntity.setCanPickupItems(false); + } + + private void applyBaseAttributes(@NonNull LivingEntity liveEntity, int maxHealth, int damage, int defense) { + if (maxHealth > 0) { + attributes.put(Attribute.MAX_HEALTH, (double) maxHealth); + } + if (damage > 0) { + attributes.put(Attribute.ATTACK_DAMAGE, (double) damage); + } + if (defense > 0) { + attributes.put(Attribute.ARMOR, (double) defense); + } + + for (Map.Entry entry : new HashMap<>(attributes).entrySet()) { + applyAttributeToLiveEntity(entry.getKey(), entry.getValue(), liveEntity); + } + } + + private void applyAttributeToLiveEntity(@NonNull Attribute attribute, double value, @NonNull LivingEntity liveEntity) { + if (!liveEntity.isValid() || liveEntity.isDead()) { + return; + } + AttributeInstance instance = liveEntity.getAttribute(attribute); + if (instance != null) { + for (AttributeModifier modifier : new ArrayList<>(instance.getModifiers())) { + instance.removeModifier(modifier); + } + instance.setBaseValue(value); + } + } + + /** + * Creates a deep copy of this attributes manager. + * + * @return detached copy + */ + @NonNull + public EntityAttributeManager copy() { + return new EntityAttributeManager(this.attributes); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java new file mode 100644 index 00000000..8f35f3e0 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java @@ -0,0 +1,182 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.bukkit.attribute.Attribute; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Fluent builder for configuring mob attributes with typed convenience methods. + */ +public final class EntityAttributesBuilder { + + private final Map attributes = new HashMap<>(); + + /** + * Sets an arbitrary Minecraft attribute value. + * + * @param attribute target attribute + * @param value base attribute value + * @return this builder + */ + public EntityAttributesBuilder set(@NonNull Attribute attribute, double value) { + this.attributes.put(Objects.requireNonNull(attribute, "attribute cannot be null"), value); + return this; + } + + /** + * Sets maximum health. + * + * @param maxHealth maximum health + * @return this builder + */ + public EntityAttributesBuilder maxHealth(double maxHealth) { + return set(Attribute.MAX_HEALTH, maxHealth); + } + + /** + * Sets attack damage. + * + * @param damage attack damage + * @return this builder + */ + public EntityAttributesBuilder attackDamage(double damage) { + return set(Attribute.ATTACK_DAMAGE, damage); + } + + /** + * Sets base armor rating. + * + * @param armor armor value + * @return this builder + */ + public EntityAttributesBuilder armor(double armor) { + return set(Attribute.ARMOR, armor); + } + + /** + * Sets base armor toughness rating. + * + * @param toughness armor toughness value + * @return this builder + */ + public EntityAttributesBuilder armorToughness(double toughness) { + return set(Attribute.ARMOR_TOUGHNESS, toughness); + } + + /** + * Sets movement speed. + * + * @param speed movement speed (vanilla default for zombies is ~0.23) + * @return this builder + */ + public EntityAttributesBuilder movementSpeed(double speed) { + return set(Attribute.MOVEMENT_SPEED, speed); + } + + /** + * Sets knockback resistance ratio (0.0 to 1.0). + * + * @param resistance knockback resistance + * @return this builder + */ + public EntityAttributesBuilder knockbackResistance(double resistance) { + return set(Attribute.KNOCKBACK_RESISTANCE, resistance); + } + + /** + * Sets entity scale multiplier (1.0 is default size). + * + * @param scale scale multiplier + * @return this builder + */ + public EntityAttributesBuilder scale(double scale) { + return set(Attribute.SCALE, scale); + } + + /** + * Sets player follow range in blocks. + * + * @param range target follow range + * @return this builder + */ + public EntityAttributesBuilder followRange(double range) { + return set(Attribute.FOLLOW_RANGE, range); + } + + /** + * Sets attack speed multiplier. + * + * @param attackSpeed attack speed + * @return this builder + */ + public EntityAttributesBuilder attackSpeed(double attackSpeed) { + return set(Attribute.ATTACK_SPEED, attackSpeed); + } + + /** + * Sets flying speed multiplier. + * + * @param flyingSpeed flying speed + * @return this builder + */ + public EntityAttributesBuilder flyingSpeed(double flyingSpeed) { + return set(Attribute.FLYING_SPEED, flyingSpeed); + } + + /** + * Sets step height in blocks (e.g. 1.0 allows stepping up 1 full block without jumping). + * + * @param stepHeight step height in blocks + * @return this builder + */ + public EntityAttributesBuilder stepHeight(double stepHeight) { + return set(Attribute.STEP_HEIGHT, stepHeight); + } + + /** + * Sets safe fall distance in blocks before taking fall damage. + * + * @param distance safe fall distance + * @return this builder + */ + public EntityAttributesBuilder safeFallDistance(double distance) { + return set(Attribute.SAFE_FALL_DISTANCE, distance); + } + + /** + * Sets fall damage multiplier. + * + * @param multiplier fall damage multiplier (0.0 disables fall damage) + * @return this builder + */ + public EntityAttributesBuilder fallDamageMultiplier(double multiplier) { + return set(Attribute.FALL_DAMAGE_MULTIPLIER, multiplier); + } + + /** + * Returns an unmodifiable snapshot of configured attributes. + * + * @return map of attributes + */ + @NonNull + public Map getAttributes() { + return Collections.unmodifiableMap(attributes); + } + + /** + * Applies all attributes from this builder to the given manager and live entity. + * + * @param manager attribute manager + * @param liveEntity live entity, or null if unspawned + */ + public void applyTo(@NonNull EntityAttributeManager manager, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(manager, "manager cannot be null"); + attributes.forEach((attribute, value) -> manager.setAttribute(attribute, value, liveEntity)); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java new file mode 100644 index 00000000..a8e2d908 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java @@ -0,0 +1,109 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.jspecify.annotations.NonNull; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; + +/** + * Tracks named ability cooldowns with thread-safe timestamp management. + */ +public final class EntityCooldowns { + + private final LongSupplier timeSupplier; + private final Map cooldowns = new ConcurrentHashMap<>(); + + /** + * Creates a cooldown tracker using {@link System#currentTimeMillis()} as time source. + */ + public EntityCooldowns() { + this(System::currentTimeMillis); + } + + /** + * Creates a cooldown tracker with an injectable time source for deterministic testing. + * + * @param timeSupplier time supplier providing current epoch milliseconds + */ + public EntityCooldowns(@NonNull LongSupplier timeSupplier) { + this.timeSupplier = Objects.requireNonNull(timeSupplier, "timeSupplier cannot be null"); + } + + /** + * Checks if the specified ability cooldown has elapsed. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis duration in milliseconds + * @return {@code true} if ready to use, {@code false} otherwise + */ + public boolean isReady(@NonNull String abilityKey, long cooldownMillis) { + Long lastTriggered = cooldowns.get(abilityKey); + return lastTriggered == null || (timeSupplier.getAsLong() - lastTriggered) >= cooldownMillis; + } + + /** + * Triggers the cooldown for the specified ability, setting its timestamp to the current time. + * + * @param abilityKey unique identifier for the ability + */ + public void trigger(@NonNull String abilityKey) { + cooldowns.put(abilityKey, timeSupplier.getAsLong()); + } + + /** + * Atomically checks if the ability is ready and triggers it if so. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis duration in milliseconds + * @return {@code true} if ready and triggered, {@code false} otherwise + */ + public boolean checkAndTrigger(@NonNull String abilityKey, long cooldownMillis) { + Objects.requireNonNull(abilityKey, "abilityKey cannot be null"); + long now = timeSupplier.getAsLong(); + AtomicBoolean triggered = new AtomicBoolean(false); + cooldowns.compute(abilityKey, (_, last) -> { + if (last == null || (now - last) >= cooldownMillis) { + triggered.set(true); + return now; + } + triggered.set(false); + return last; + }); + return triggered.get(); + } + + /** + * Resets the cooldown for the specified ability, allowing immediate reuse. + * + * @param abilityKey unique identifier for the ability + */ + public void reset(@NonNull String abilityKey) { + cooldowns.remove(abilityKey); + } + + /** + * Resets all tracked ability cooldowns. + */ + public void resetAll() { + cooldowns.clear(); + } + + /** + * Returns the remaining cooldown duration in milliseconds. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis total cooldown duration in milliseconds + * @return remaining milliseconds, or 0 if cooldown has expired + */ + public long getRemainingMillis(@NonNull String abilityKey, long cooldownMillis) { + Long lastTriggered = cooldowns.get(abilityKey); + if (lastTriggered == null) { + return 0; + } + long elapsed = timeSupplier.getAsLong() - lastTriggered; + return Math.max(0, cooldownMillis - elapsed); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java new file mode 100644 index 00000000..6823564f --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java @@ -0,0 +1,168 @@ +package fr.moussax.blightedSMP.engine.entities.util; + +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +/** + * Utility functions for dispatching sound effects, particle clouds, and animation states. + */ +public final class EntityEffects { + + private EntityEffects() { + } + + /** + * Plays a sound effect at the entity's current location. + * + * @param entity target entity + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public static void playSound(@Nullable LivingEntity entity, @NonNull Sound sound, float volume, float pitch) { + Objects.requireNonNull(sound, "sound cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().playSound(entity.getLocation(), sound, volume, pitch); + } + } + + /** + * Plays a sound effect at a specific world location. + * + * @param location location to play the sound at + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public static void playSound(@NonNull Location location, @NonNull Sound sound, float volume, float pitch) { + Objects.requireNonNull(location, "location cannot be null"); + Objects.requireNonNull(sound, "sound cannot be null"); + if (location.getWorld() != null) { + location.getWorld().playSound(location, sound, volume, pitch); + } + } + + /** + * Spawns particles at the entity's current location. + * + * @param entity target entity + * @param particle particle type + * @param count number of particles + */ + public static void spawnParticle(@Nullable LivingEntity entity, @NonNull Particle particle, int count) { + Objects.requireNonNull(particle, "particle cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().spawnParticle(particle, entity.getLocation(), count); + } + } + + /** + * Spawns particles at the entity's current location with random positional offsets. + * + * @param entity target entity + * @param particle particle type + * @param count number of particles + * @param offsetX maximum X axis offset + * @param offsetY maximum Y axis offset + * @param offsetZ maximum Z axis offset + */ + public static void spawnParticle( + @Nullable LivingEntity entity, + @NonNull Particle particle, + int count, + double offsetX, + double offsetY, + double offsetZ + ) { + Objects.requireNonNull(particle, "particle cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().spawnParticle(particle, entity.getLocation(), count, offsetX, offsetY, offsetZ); + } + } + + /** + * Spawns particles at the entity's current location with custom particle data. + * + * @param entity target entity + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public static void spawnParticle( + @Nullable LivingEntity entity, + @NonNull Particle particle, + int count, + @Nullable T data + ) { + Objects.requireNonNull(particle, "particle cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().spawnParticle(particle, entity.getLocation(), count, 0.0, 0.0, 0.0, data); + } + } + + /** + * Spawns particles at a specific world location. + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + */ + public static void spawnParticle(@NonNull Location location, @NonNull Particle particle, int count) { + Objects.requireNonNull(location, "location cannot be null"); + Objects.requireNonNull(particle, "particle cannot be null"); + if (location.getWorld() != null) { + location.getWorld().spawnParticle(particle, location, count); + } + } + + /** + * Spawns particles at a specific world location with custom particle data. + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public static void spawnParticle( + @NonNull Location location, + @NonNull Particle particle, + int count, + @Nullable T data + ) { + Objects.requireNonNull(location, "location cannot be null"); + Objects.requireNonNull(particle, "particle cannot be null"); + if (location.getWorld() != null) { + location.getWorld().spawnParticle(particle, location, count, 0.0, 0.0, 0.0, data); + } + } + + /** + * Triggers the entity's main-hand swinging animation. + * + * @param entity target entity + */ + public static void swingMainHand(@Nullable LivingEntity entity) { + if (entity != null && entity.isValid()) { + entity.swingMainHand(); + } + } + + /** + * Triggers the entity's off-hand swinging animation. + * + * @param entity target entity + */ + public static void swingOffHand(@Nullable LivingEntity entity) { + if (entity != null && entity.isValid()) { + entity.swingOffHand(); + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java new file mode 100644 index 00000000..dd96ae08 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java @@ -0,0 +1,178 @@ +package fr.moussax.blightedSMP.engine.entities.util; + +import fr.moussax.blightedSMP.engine.player.BlightedPlayer; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * Utility methods for spatial searches, targeting queries, and area-of-effect combat mechanics. + */ +public final class EntitySpatialQueries { + + private EntitySpatialQueries() { + } + + /** + * Finds survival-mode players within a spherical radius around a center location. + * + * @param center search center + * @param radius search radius + * @return matching survival players, or empty list if center is invalid + */ + public static List getNearbyPlayers(@Nullable Location center, double radius) { + if (center == null || center.getWorld() == null || radius <= 0) { + return Collections.emptyList(); + } + return center.getWorld() + .getNearbyEntities( + center, + radius, + radius, + radius, + entity -> entity instanceof Player player && player.getGameMode() == GameMode.SURVIVAL + ) + .stream() + .map(entity -> (Player) entity) + .toList(); + } + + /** + * Finds the nearest survival-mode player to a center location within the given radius. + * + * @param center search center + * @param radius search radius + * @return nearest player, or {@code null} if none found + */ + @Nullable + public static Player getNearestPlayer(@Nullable Location center, double radius) { + if (center == null) { + return null; + } + return getNearbyPlayers(center, radius).stream() + .min(Comparator.comparingDouble(player -> player.getLocation().distanceSquared(center))) + .orElse(null); + } + + /** + * Finds nearby {@link BlightedPlayer} instances around a center location. + * + * @param center search center + * @param radius search radius + * @return list of nearby BlightedPlayer instances + */ + public static List getNearbyBlightedPlayers(@Nullable Location center, double radius) { + return getNearbyPlayers(center, radius).stream() + .map(BlightedPlayer::get) + .filter(Objects::nonNull) + .toList(); + } + + /** + * Finds the nearest {@link BlightedPlayer} to a center location. + * + * @param center search center + * @param radius search radius + * @return nearest BlightedPlayer, or {@code null} + */ + @Nullable + public static BlightedPlayer getNearestBlightedPlayer(@Nullable Location center, double radius) { + Player target = getNearestPlayer(center, radius); + return target != null ? BlightedPlayer.get(target) : null; + } + + /** + * Damages all nearby survival-mode players around a center location. + * + * @param center damage center + * @param radius damage radius + * @param damageAmount damage dealt + * @param source attacking entity responsible for the damage + */ + public static void damageNearbyPlayers( + @Nullable Location center, + double radius, + double damageAmount, + @Nullable Entity source + ) { + if (center == null) return; + List hitPlayers = getNearbyPlayers(center, radius); + for (Player player : hitPlayers) { + player.damage(damageAmount, source); + } + } + + /** + * Damages and knocks back all nearby survival-mode players away from the center. + * + * @param center effect center + * @param radius effect radius + * @param damageAmount damage dealt + * @param knockbackStrength horizontal knockback strength + * @param verticalKnockback vertical velocity + * @param source attacking entity responsible for the effect + */ + public static void damageAndKnockbackNearbyPlayers( + @Nullable Location center, + double radius, + double damageAmount, + double knockbackStrength, + double verticalKnockback, + @Nullable Entity source + ) { + if (center == null) return; + List hitPlayers = getNearbyPlayers(center, radius); + for (Player player : hitPlayers) { + player.damage(damageAmount, source); + Vector knockbackVector = player.getLocation().toVector().subtract(center.toVector()).setY(0); + if (knockbackVector.lengthSquared() > 0.001) { + knockbackVector.normalize().multiply(knockbackStrength).setY(verticalKnockback); + } else { + knockbackVector = new Vector(0, verticalKnockback, 0); + } + player.setVelocity(knockbackVector); + } + } + + /** + * Disables a target player's shield if they are currently blocking. + * + * @param target target player + * @param cooldownTicks duration in ticks to put shield on cooldown + */ + public static void disableShieldIfBlocking(@Nullable Player target, int cooldownTicks) { + if (target != null && target.isBlocking()) { + target.setCooldown(Material.SHIELD, cooldownTicks); + target.getWorld().playSound(target.getLocation(), Sound.ITEM_SHIELD_BREAK, 1.0f, 0.8f); + } + } + + /** + * Rotates an entity on the horizontal plane to face a target location. + * + * @param entity entity to rotate + * @param target target location + */ + public static void faceLocation(@Nullable LivingEntity entity, @Nullable Location target) { + if (entity == null || !entity.isValid() || entity.isDead() || target == null) { + return; + } + Vector direction = target.toVector().subtract(entity.getLocation().toVector()).setY(0); + if (direction.lengthSquared() > 0.001) { + Location location = entity.getLocation(); + location.setYaw((float) Math.toDegrees(-Math.atan2(direction.getX(), direction.getZ()))); + entity.teleport(location); + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java index e746321d..c6423f6d 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java @@ -59,7 +59,7 @@ public static EntityResult vanilla(EntityType entityType, Consumer */ public static EntityResult blighted(fr.moussax.blightedSMP.engine.entities.BlightedEntity blightedEntity) { Objects.requireNonNull(blightedEntity, "blightedEntity cannot be null"); - return custom(blightedEntity.clone()::spawn); + return custom(loc -> blightedEntity.createInstance().spawn(loc)); } /** diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java index 6c076ca9..0d3bb541 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java @@ -3,7 +3,7 @@ import fr.moussax.bedrock.ui.title.TimeableTitle; import fr.moussax.bedrock.ui.title.Title; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.blightedSMP.engine.items.registry.ItemRegistry; import org.bukkit.ChatColor; import org.bukkit.Sound; @@ -82,7 +82,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { if (killer == null) return; - BlightedEntity blighted = BlightedEntitiesListener.getBlightedEntity(killer); + BlightedEntity blighted = EntityManager.getBlightedEntity(killer); if (blighted == null) return; String victimName = event.getEntity().getName(); @@ -104,7 +104,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { } private void clearTargetedMobs(Player targetPlayer) { - for (BlightedEntity blighted : BlightedEntitiesListener.getActiveEntities()) { + for (BlightedEntity blighted : EntityManager.getActiveEntities()) { LivingEntity entity = blighted.getEntity(); if (entity instanceof Mob mob && targetPlayer.equals(mob.getTarget())) { mob.setTarget(null); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java index ec97b8c1..e1072ee0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java @@ -3,7 +3,7 @@ import fr.moussax.blightedSMP.BlightedSMP; import fr.moussax.blightedSMP.engine.items.BlightedItem; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.blightedSMP.content.entities.factions.blightsworn.BlightswornCreature; import fr.moussax.bedrock.text.Messenger; import org.bukkit.Bukkit; @@ -39,7 +39,7 @@ public void onEntityDeath(EntityDeathEvent event) { Player killer = deadEntity.getKiller(); if (killer != null) { - BlightedEntity blightedEntity = BlightedEntitiesListener.getBlightedEntity(deadEntity); + BlightedEntity blightedEntity = EntityManager.getBlightedEntity(deadEntity); if (blightedEntity instanceof BlightswornCreature) { incrementCodexTrappedSouls(killer); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java index c06df5aa..f17e88b0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.server; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.bedrock.utils.debug.Log; import org.bukkit.*; import org.bukkit.plugin.java.JavaPlugin; @@ -58,7 +58,7 @@ public void run() { Chunk chunk = iterator.next(); if (chunk.isLoaded()) { - BlightedEntitiesListener.rehydrateChunk(chunk); + EntityManager.rehydrateChunk(chunk); } currentBatch++; diff --git a/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java new file mode 100644 index 00000000..11712215 --- /dev/null +++ b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java @@ -0,0 +1,141 @@ +package fr.moussax.blightedSMP.engine.entities.spawnable; + +import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; +import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class SpawningRuleTest { + + private final SpawnCondition alwaysTrue = (_, _) -> true; + private final SpawnCondition alwaysFalse = (_, _) -> false; + + @Test + @DisplayName("anyOf should pass if at least one condition passes") + void testAnyOf() { + assertFalse(SpawnCondition.anyOf().testCanSpawnAt(null, null), "Empty anyOf should evaluate to false"); + + assertTrue(SpawnCondition.anyOf(alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.anyOf(alwaysFalse).testCanSpawnAt(null, null)); + + assertTrue(SpawnCondition.anyOf(alwaysFalse, alwaysTrue, alwaysFalse).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.anyOf(alwaysFalse, alwaysFalse).testCanSpawnAt(null, null)); + + // Short-circuiting verification + AtomicBoolean evaluated = new AtomicBoolean(false); + SpawnCondition probe = (_, _) -> { + evaluated.set(true); + return true; + }; + + boolean result = SpawnCondition.anyOf(alwaysTrue, probe).testCanSpawnAt(null, null); + assertTrue(result); + assertFalse(evaluated.get(), "anyOf should short-circuit upon first matching condition"); + } + + @Test + @DisplayName("allOf should pass only if all conditions pass") + void testAllOf() { + assertTrue(SpawnCondition.allOf().testCanSpawnAt(null, null), "Empty allOf should evaluate to true"); + + assertTrue(SpawnCondition.allOf(alwaysTrue, alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.allOf(alwaysTrue, alwaysFalse).testCanSpawnAt(null, null)); + + // Short-circuiting verification + AtomicBoolean evaluated = new AtomicBoolean(false); + SpawnCondition probe = (_, _) -> { + evaluated.set(true); + return true; + }; + + boolean result = SpawnCondition.allOf(alwaysFalse, probe).testCanSpawnAt(null, null); + assertFalse(result); + assertFalse(evaluated.get(), "allOf should short-circuit upon first failing condition"); + } + + @Test + @DisplayName("noneOf should pass only if no conditions pass") + void testNoneOf() { + assertTrue(SpawnCondition.noneOf(alwaysFalse, alwaysFalse).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.noneOf(alwaysFalse, alwaysTrue).testCanSpawnAt(null, null)); + } + + @Test + @DisplayName("Default combinators and, or, negate should evaluate correctly") + void testDefaultCombinators() { + assertTrue(alwaysTrue.and(alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(alwaysTrue.and(alwaysFalse).testCanSpawnAt(null, null)); + + assertTrue(alwaysFalse.or(alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(alwaysFalse.or(alwaysFalse).testCanSpawnAt(null, null)); + + assertFalse(alwaysTrue.negate().testCanSpawnAt(null, null)); + assertTrue(alwaysFalse.negate().testCanSpawnAt(null, null)); + } + + @Test + @DisplayName("SpawningBuilder correctly configures probability, mode, and conditions") + void testSpawningBuilder() { + SpawningBuilder builder = new SpawningBuilder(); + + assertEquals(SpawningBuilder.DEFAULT_PROBABILITY, builder.getProbability()); + assertEquals(SpawningBuilder.DEFAULT_MODE, builder.getMode()); + + builder.probability(0.05) + .mode(SpawnMode.INDEPENDENT) + .anyOf(alwaysTrue, alwaysFalse) + .condition(alwaysTrue); + + assertEquals(0.05, builder.getProbability(), 1e-6); + assertEquals(SpawnMode.INDEPENDENT, builder.getMode()); + assertEquals(2, builder.getConditions().size()); + + SpawnProfile profile = builder.buildProfile(); + assertNotNull(profile); + assertEquals(2, profile.conditions().size()); + } + + @Test + @DisplayName("SpawningBuilder should reject out-of-range probabilities") + void testProbabilityValidation() { + SpawningBuilder builder = new SpawningBuilder(); + assertThrows(IllegalArgumentException.class, () -> builder.probability(-0.01)); + assertThrows(IllegalArgumentException.class, () -> builder.probability(1.01)); + } + + @Test + @DisplayName("SpawningBuilder should properly configure and validate elite affixes") + void testAffixesConfiguration() { + SpawningBuilder builder = new SpawningBuilder(); + assertEquals(0.0, builder.getAffixChance()); + assertEquals(1, builder.getMaxAffixes()); + + builder.affixes(0.25, 3); + assertEquals(0.25, builder.getAffixChance(), 1e-6); + assertEquals(3, builder.getMaxAffixes()); + + builder.affixes(0.10); + assertEquals(0.10, builder.getAffixChance(), 1e-6); + assertEquals(1, builder.getMaxAffixes()); + + assertThrows(IllegalArgumentException.class, () -> builder.affixes(-0.1)); + assertThrows(IllegalArgumentException.class, () -> builder.affixes(1.1)); + } + + @Test + @DisplayName("SpawningBuilder should configure environment conditions") + void testEnvironmentConditions() { + SpawningBuilder builder = new SpawningBuilder(); + builder.overworld() + .nether() + .theEnd() + .environment(org.bukkit.World.Environment.CUSTOM); + + assertEquals(4, builder.getConditions().size()); + assertEquals(4, builder.buildProfile().conditions().size()); + } +} diff --git a/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java new file mode 100644 index 00000000..b0f94b81 --- /dev/null +++ b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java @@ -0,0 +1,82 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class EntityCooldownsTest { + + @Test + @DisplayName("checkAndTrigger should reject subsequent calls within the same millisecond") + void testCheckAndTriggerSameMillisecond() { + AtomicLong currentTime = new AtomicLong(1000L); + EntityCooldowns cooldowns = new EntityCooldowns(currentTime::get); + + assertTrue(cooldowns.checkAndTrigger("slam", 500L), "Initial trigger should be accepted"); + assertFalse(cooldowns.checkAndTrigger("slam", 500L), "Same millisecond trigger must be rejected"); + + currentTime.set(1200L); + assertFalse(cooldowns.checkAndTrigger("slam", 500L), "Trigger before cooldown expires must be rejected"); + + currentTime.set(1500L); + assertTrue(cooldowns.checkAndTrigger("slam", 500L), "Trigger after cooldown expires must be accepted"); + assertFalse(cooldowns.checkAndTrigger("slam", 500L), "Immediate second trigger must be rejected"); + } + + @Test + @DisplayName("isReady and trigger should manage timestamps accurately") + void testIsReadyAndTrigger() { + AtomicLong currentTime = new AtomicLong(1000L); + EntityCooldowns cooldowns = new EntityCooldowns(currentTime::get); + + assertTrue(cooldowns.isReady("dash", 300L)); + assertEquals(0L, cooldowns.getRemainingMillis("dash", 300L)); + + cooldowns.trigger("dash"); + assertFalse(cooldowns.isReady("dash", 300L)); + assertEquals(300L, cooldowns.getRemainingMillis("dash", 300L)); + + currentTime.addAndGet(100L); + assertFalse(cooldowns.isReady("dash", 300L)); + assertEquals(200L, cooldowns.getRemainingMillis("dash", 300L)); + + currentTime.addAndGet(200L); + assertTrue(cooldowns.isReady("dash", 300L)); + assertEquals(0L, cooldowns.getRemainingMillis("dash", 300L)); + } + + @Test + @DisplayName("reset and resetAll should clear cooldowns") + void testReset() { + AtomicLong currentTime = new AtomicLong(1000L); + EntityCooldowns cooldowns = new EntityCooldowns(currentTime::get); + + cooldowns.trigger("abilityA"); + cooldowns.trigger("abilityB"); + + assertFalse(cooldowns.isReady("abilityA", 1000L)); + assertFalse(cooldowns.isReady("abilityB", 1000L)); + + cooldowns.reset("abilityA"); + assertTrue(cooldowns.isReady("abilityA", 1000L)); + assertFalse(cooldowns.isReady("abilityB", 1000L)); + + cooldowns.resetAll(); + assertTrue(cooldowns.isReady("abilityB", 1000L)); + } + + @Test + @DisplayName("Null checks throw NullPointerException") + void testNullChecks() { + EntityCooldowns cooldowns = new EntityCooldowns(); + + assertThrows(NullPointerException.class, () -> cooldowns.checkAndTrigger(null, 100L)); + assertThrows(NullPointerException.class, () -> cooldowns.isReady(null, 100L)); + assertThrows(NullPointerException.class, () -> cooldowns.trigger(null)); + assertThrows(NullPointerException.class, () -> cooldowns.reset(null)); + assertThrows(NullPointerException.class, () -> cooldowns.getRemainingMillis(null, 100L)); + } +} diff --git a/pom.xml b/pom.xml index c5c245ea..e47de564 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,11 @@ maven-shade-plugin 3.5.3 + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 +