diff --git a/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java b/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java new file mode 100644 index 000000000..0c1bfc34c --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java @@ -0,0 +1,80 @@ +package com.nisovin.magicspells.castmodifiers.conditions; + + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.LivingEntity; +import org.bukkit.scoreboard.Objective; +import org.bukkit.scoreboard.Scoreboard; + +import com.nisovin.magicspells.castmodifiers.Condition; +import com.nisovin.magicspells.util.Name; + +/** + * Usage: score required/denied + * Example: score kills>3 required + */ +@Name("score") +public class ScoreCondition extends Condition { + + private String objectiveName; + private String operator; + private int value; + + @Override + public boolean initialize(String var) { + if (var == null) return false; + var = var.trim(); + String[] ops = {">=","<=","!=",">","<","="}; + for (String op : ops) { + int idx = var.indexOf(op); + if (idx > 0) { + objectiveName = var.substring(0, idx).trim(); + operator = op; + try { + value = Integer.parseInt(var.substring(idx + op.length()).trim()); + } catch (NumberFormatException e) { + return false; + } + return true; + } + } + return false; + } + + @Override + public boolean check(LivingEntity entity) { + return checkScore(entity); + } + + @Override + public boolean check(LivingEntity caster, LivingEntity target) { + return checkScore(target); + } + + @Override + public boolean check(LivingEntity caster, Location location) { + // Not applicable for locations, always false + return false; + } + + private boolean checkScore(LivingEntity entity) { + Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + Objective obj = scoreboard.getObjective(objectiveName); + if (obj == null) return false; + int score = obj.getScoreFor(entity).getScore(); + return compare(score, operator, value); + } + + private boolean compare(int score, String op, int val) { + switch (op) { + case ">": return score > val; + case "<": return score < val; + case ">=": return score >= val; + case "<=": return score <= val; + case "=": return score == val; + case "!=": return score != val; + default: return false; + } + } +} diff --git a/core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java b/core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java new file mode 100644 index 000000000..8805d6f2e --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java @@ -0,0 +1,132 @@ +package com.nisovin.magicspells.spells.buff; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.jetbrains.annotations.NotNull; + +import org.bukkit.entity.LivingEntity; +import org.bukkit.event.Listener; +import org.bukkit.event.EventHandler; +import org.bukkit.event.entity.EntityDamageByEntityEvent; + +import com.nisovin.magicspells.events.SpellTargetEvent; +import com.nisovin.magicspells.events.SpellPreImpactEvent; +import com.nisovin.magicspells.events.MagicSpellsEntityDamageByEntityEvent; +import com.nisovin.magicspells.spells.BuffSpell; +import com.nisovin.magicspells.spelleffects.EffectPosition; +import com.nisovin.magicspells.util.MagicConfig; +import com.nisovin.magicspells.util.SpellData; + +public class ProxySpell extends BuffSpell implements Listener { + + private final Set redirecting = new HashSet<>(); + private final Map proxies = new HashMap<>(); + + public ProxySpell(MagicConfig config, String spellName) { + super(config, spellName); + } + } + + @Override + public boolean castBuff(SpellData data) { + proxies.put(data.target().getUniqueId(), data); + return true; + } + + @Override + public boolean recastBuff(SpellData data) { + stopEffects(data.target()); + return castBuff(data); + } + + @Override + public boolean isActive(LivingEntity entity) { + return proxies.containsKey(entity.getUniqueId()); + } + + @Override + public void turnOffBuff(LivingEntity entity) { + proxies.remove(entity.getUniqueId()); + } + + @Override + protected @NotNull Collection getActiveEntities() { + return proxies.keySet(); + } + + @EventHandler(ignoreCancelled = true) + public void onSpellTarget(SpellTargetEvent event) { + LivingEntity target = event.getTarget(); + if (target == null || !target.isValid()) return; + + LivingEntity proxyTarget = getProxyTarget(target); + if (proxyTarget == null) return; + + event.setTarget(proxyTarget); + playRedirectEffects(target, proxyTarget, event.getSpellData()); + + addUseAndChargeCost(target); + } + + @EventHandler(ignoreCancelled = true) + public void onSpellPreImpact(SpellPreImpactEvent event) { + LivingEntity target = event.getTarget(); + if (target == null || !target.isValid()) return; + + LivingEntity proxyTarget = getProxyTarget(target); + if (proxyTarget == null) return; + + SpellData subData = new SpellData(event.getCaster(), proxyTarget, event.getPower()); + playRedirectEffects(target, proxyTarget, subData); + + event.setRedirected(true); + addUseAndChargeCost(target); + } + + @EventHandler(ignoreCancelled = true) + public void onEntityDamage(EntityDamageByEntityEvent event) { + if (!(event.getEntity() instanceof LivingEntity target)) return; + + LivingEntity proxyTarget = getProxyTarget(target); + if (proxyTarget == null) return; + if (!redirecting.add(proxyTarget.getUniqueId())) return; + + SpellData subData = new SpellData(event.getDamager() instanceof LivingEntity damager ? damager : null, proxyTarget); + playRedirectEffects(target, proxyTarget, subData); + + event.setCancelled(true); + try { + proxyTarget.damage(event.getDamage(), event.getDamager()); + addUseAndChargeCost(target); + } finally { + redirecting.remove(proxyTarget.getUniqueId()); + } + } + + @EventHandler(ignoreCancelled = true) + public void onLegacyDamage(MagicSpellsEntityDamageByEntityEvent event) { + onEntityDamage(event); + } + + private LivingEntity getProxyTarget(LivingEntity target) { + SpellData proxyData = proxies.get(target.getUniqueId()); + if (proxyData == null) return null; + + LivingEntity proxyTarget = proxyData.caster(); + if (proxyTarget != null && proxyTarget.isValid()) return proxyTarget; + + turnOff(target); + return null; + } + + private void playRedirectEffects(LivingEntity target, LivingEntity proxyTarget, SpellData data) { + playSpellEffects(EffectPosition.TARGET, target, data); + playSpellEffects(EffectPosition.END_POSITION, proxyTarget, data); + } + +} diff --git a/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java new file mode 100644 index 000000000..afc8e1277 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java @@ -0,0 +1,147 @@ +package com.nisovin.magicspells.spells.instant; + +import java.util.*; + +import org.bukkit.Location; +import org.bukkit.util.Vector; +import org.bukkit.entity.LivingEntity; +import org.bukkit.scheduler.BukkitRunnable; + +import com.nisovin.magicspells.Subspell; +import com.nisovin.magicspells.MagicSpells; +import com.nisovin.magicspells.util.SpellData; +import com.nisovin.magicspells.util.CastResult; +import com.nisovin.magicspells.util.MagicConfig; +import com.nisovin.magicspells.spells.InstantSpell; +import com.nisovin.magicspells.util.config.ConfigData; +import com.nisovin.magicspells.spelleffects.EffectPosition; + +public class BranchingProjectileSpell extends InstantSpell { + + private final ConfigData maxDistance; + private final ConfigData hitRadius; + private final ConfigData stepLength; + private final ConfigData branchAngle; + private final ConfigData branchProbability; + + private final ConfigData maxDuration; + private final ConfigData minBranchLength; + private final ConfigData maxBranchLength; + + private final ConfigData spellToCastName; + private Subspell spellToCast; + + public BranchingProjectileSpell(MagicConfig config, String spellName) { + super(config, spellName); + + maxDuration = getConfigDataInt("max-duration", 40); + + minBranchLength = getConfigDataInt("min-branch-length", 3); + maxBranchLength = getConfigDataInt("max-branch-length", 8); + + hitRadius = getConfigDataDouble("hit-radius", 1.5); + stepLength = getConfigDataDouble("step-length", 0.8); + maxDistance = getConfigDataDouble("max-distance", 20.0); + branchAngle = getConfigDataDouble("branch-angle", 30.0); + branchProbability = getConfigDataDouble("branch-probability", 0.3); + + spellToCastName = getConfigDataString("spell", ""); + } + + @Override + public void initialize() { + super.initialize(); + spellToCast = initSubspell(spellToCastName.get(null), "BranchingProjectileSpell '" + internalName + "' has an invalid spell: '" + spellToCastName.get(null) + "' defined!"); + } + + @Override + public CastResult cast(SpellData data) { + Location start = data.caster().getEyeLocation(); + Vector direction = start.getDirection().normalize(); + + double maxDist = maxDistance.get(data); + int maxTicks = maxDuration.get(data); + new BranchTask(start, direction, maxDist, maxTicks, data, 0, false).runTaskTimer(MagicSpells.plugin, 0, 1); + return new CastResult(PostCastAction.HANDLE_NORMALLY, data); + } + + private class BranchTask extends BukkitRunnable { + + private final Location current; + private final Vector direction; + private final double maxDist; + private final int maxTicks; + private final SpellData data; + private final int branchDepth; + private final boolean isBranch; + private double traveled = 0; + private int ticks = 0; + private int branchLength = 0; + private final int branchMaxLength; + + BranchTask(Location start, Vector direction, double maxDist, int maxTicks, SpellData data, int branchDepth, boolean isBranch) { + this.current = start.clone(); + this.direction = direction.clone(); + this.maxDist = maxDist; + this.maxTicks = maxTicks; + this.data = data; + this.branchDepth = branchDepth; + this.isBranch = isBranch; + this.branchMaxLength = isBranch ? random.nextInt(minBranchLength.get(data), maxBranchLength.get(data)) : Integer.MAX_VALUE; + } + + @Override + public void run() { + boolean finished = (traveled >= maxDist || ticks >= maxTicks || branchLength >= branchMaxLength); + if (finished) { + // Play DELAYED effect at the end of the branch/trunk + playSpellEffects(EffectPosition.DELAYED, current, data.location(current)); + cancel(); + return; + } + // Add a small random curve to the direction for organic movement (branches only) + if (isBranch) { + double curveStrength = 0.15; // tweak for more/less curve + Vector curve = new Vector( + (Math.random() - 0.5) * curveStrength, + (Math.random() - 0.5) * curveStrength, + (Math.random() - 0.5) * curveStrength + ); + direction.add(curve); + direction.normalize(); + } + // Move forward + current.add(direction.clone().multiply(stepLength.get(data))); + traveled += stepLength.get(data); + branchLength++; + ticks++; + // Play particle effect using appropriate EffectPosition + EffectPosition pos = isBranch ? EffectPosition.SPECIAL : EffectPosition.PROJECTILE; + playSpellEffects(pos, current, data.location(current)); + // Hit detection + for (LivingEntity entity : current.getNearbyLivingEntities(hitRadius.get(data))) { + if (entity.equals(data.caster())) continue; + if (spellToCast != null) spellToCast.subcast(data.retarget(entity, current)); + } + // Branching + if (!isBranch && Math.random() < branchProbability.get(data)) { + Vector branchDir = getBranchDirection(direction, branchAngle.get(data)); + new BranchTask(current.clone(), branchDir, maxDist, maxTicks, data, branchDepth + 1, true).runTaskTimer(MagicSpells.plugin, 0, 1); + } + } + } + + private Vector getBranchDirection(Vector base, double angleDeg) { + double angleRad = Math.toRadians(angleDeg); + double yaw = Math.atan2(base.getZ(), base.getX()); + double pitch = Math.asin(base.getY()); + double branchYaw = yaw + (Math.random() - 0.5) * angleRad; + double branchPitch = pitch + (Math.random() - 0.5) * (angleRad / 2); + double x = Math.cos(branchYaw) * Math.cos(branchPitch); + double y = Math.sin(branchPitch); + double z = Math.sin(branchYaw) * Math.cos(branchPitch); + return new Vector(x, y, z).normalize(); + } + + +} diff --git a/core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java new file mode 100644 index 000000000..42a459f5c --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java @@ -0,0 +1,307 @@ +package com.nisovin.magicspells.spells.targeted; + +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.LivingEntity; +import org.bukkit.util.Vector; + +import com.nisovin.magicspells.util.*; +import com.nisovin.magicspells.Subspell; +import com.nisovin.magicspells.spells.TargetedSpell; +import com.nisovin.magicspells.util.config.ConfigData; +import com.nisovin.magicspells.spelleffects.EffectPosition; +import com.nisovin.magicspells.spells.TargetedLocationSpell; +import com.nisovin.magicspells.spells.TargetedEntitySpell; +import com.nisovin.magicspells.events.SpellTargetLocationEvent; + +public class PathfindSpell extends TargetedSpell implements TargetedLocationSpell, TargetedEntitySpell { + + private final ConfigData maxPathLength; + private final ConfigData allowDiagonal; + private final ConfigData spellToCastName; + private final ConfigData spellOnEndName; + private final ConfigData maxStepHeight; + private final ConfigData travelThroughBlocks; + private List walkableBlocks; + private List deniedBlocks; + + private Subspell spellToCast; + private Subspell spellOnEnd; + private Set walkableBlockData; + private Set deniedBlockData; + + public PathfindSpell(MagicConfig config, String spellName) { + super(config, spellName); + maxPathLength = getConfigDataInt("max-path-length", 128); + allowDiagonal = getConfigDataBoolean("allow-diagonal", false); + spellToCastName = getConfigDataString("spell", ""); + spellOnEndName = getConfigDataString("spell-on-end", ""); + maxStepHeight = getConfigDataInt("max-step-height", 1); + travelThroughBlocks = getConfigDataBoolean("travel-through-blocks", false); + walkableBlocks = getConfigStringList("walkable-blocks", null); + deniedBlocks = getConfigStringList("denied-blocks", null); + } + + @Override + public void initialize() { + super.initialize(); + spellToCast = initSubspell(spellToCastName.get(null), + "PathfindSpell '" + internalName + "' has an invalid spell: '" + spellToCastName.get(null) + "' defined!"); + spellOnEnd = initSubspell(spellOnEndName.get(null), + "PathfindSpell '" + internalName + "' has an invalid spell-on-end: '" + spellOnEndName.get(null) + "' defined!"); + walkableBlockData = parseBlockDataSet(walkableBlocks); + deniedBlockData = parseBlockDataSet(deniedBlocks); + } + + @Override + public CastResult cast(SpellData data) { + TargetInfo entityInfo = getTargetedEntity(data); + if (!entityInfo.empty()) return castAtEntity(entityInfo.spellData()); + + TargetInfo locationInfo = getTargetedBlockLocation(data); + if (locationInfo.noTarget()) return noTarget(locationInfo); + + return castAtLocation(locationInfo.spellData()); + } + + @Override + public CastResult castAtLocation(SpellData data) { + if (!data.hasCaster()) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + SpellTargetLocationEvent targetEvent = new SpellTargetLocationEvent(this, data, data.location()); + if (!targetEvent.callEvent()) return noTarget(targetEvent); + + data = targetEvent.getSpellData(); + + Location from = data.caster().getLocation(); + Location to = data.location(); + return castPath(from, to, data); + } + + @Override + public CastResult castAtEntity(SpellData data) { + if (!data.hasCaster() || !data.hasTarget()) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + Location from = data.caster().getLocation(); + Location to = data.target().getLocation(); + return castPath(from, to, data); + } + + private CastResult castPath(Location from, Location to, SpellData data) { + boolean throughBlocks = travelThroughBlocks.get(data); + + // Snap caster and target locations to nearby walkable nodes so we don't try to + // path into solid blocks or inside walls. + Location start = findNearbyWalkable(from, throughBlocks); + Location goal = findNearbyWalkable(to, throughBlocks); + + boolean diagonal = allowDiagonal.get(data); + Integer maxStepValue = maxStepHeight.get(data); + int maxStep = maxStepValue != null ? maxStepValue : 1; + + Set attempted = new HashSet<>(); + List path = findPath(start, goal, maxPathLength.get(data), diagonal, maxStep, throughBlocks, attempted); + if (path == null || path.isEmpty()) { + // Play disabled effect at each attempted node + for (Location loc : attempted) { + playSpellEffects(EffectPosition.DISABLED, loc.clone().add(0.5, 0.5, 0.5), data.location(loc.clone().add(0.5, 0.5, 0.5))); + } + return noTarget(data); + } + for (Location loc : path) { + if (!shouldDisplayNode(loc, throughBlocks)) continue; + + SpellData subData = data.location(loc); + if (spellToCast != null) spellToCast.subcast(subData); + playSpellEffects(EffectPosition.TARGET, loc, subData); + } + // Play DELAYED effect and spell-on-end at the final location + if (!path.isEmpty()) { + Location end = path.get(path.size() - 1); + playSpellEffects(EffectPosition.DELAYED, end, data.location(end)); + if (spellOnEnd != null) spellOnEnd.subcast(data.location(end)); + } + if (data.hasCaster()) playSpellEffects(EffectPosition.CASTER, data.caster(), data); + return new CastResult(PostCastAction.HANDLE_NORMALLY, data); + } + + private List findPath(Location start, Location goal, int maxLength, boolean allowDiagonal, int maxStep, boolean throughBlocks, Set attempted) { + // Simple 3D A* implementation with block-aligned locations for closed set + class Node implements Comparable { + final int x, y, z; + final String world; + Node parent; + double g, h; + Node(Location loc, Node parent, double g, double h) { + this.x = loc.getBlockX(); + this.y = loc.getBlockY(); + this.z = loc.getBlockZ(); + this.world = loc.getWorld().getName(); + this.parent = parent; + this.g = g; + this.h = h; + } + Location toLocation(org.bukkit.World w) { return new Location(w, x, y, z); } + double f() { return g + h; } + @Override public int compareTo(Node o) { return Double.compare(f(), o.f()); } + @Override public boolean equals(Object o) { + if (!(o instanceof Node n)) return false; + return x == n.x && y == n.y && z == n.z && world.equals(n.world); + } + @Override public int hashCode() { return Objects.hash(x, y, z, world); } + } + Set closed = new HashSet<>(); + PriorityQueue open = new PriorityQueue<>(); + org.bukkit.World startWorld = start.getWorld(); + org.bukkit.World goalWorld = goal.getWorld(); + if (startWorld == null || goalWorld == null || !startWorld.equals(goalWorld)) { + return null; + } + org.bukkit.World world = startWorld; + Node startNode = new Node(start, null, 0, start.distance(goal)); + Node goalNode = new Node(goal, null, 0, 0); + open.add(startNode); + int expanded = 0; + int maxNodes = 10000; // hard limit to prevent infinite loops + while (!open.isEmpty() && expanded < maxNodes) { + Node curr = open.poll(); + if (curr.x == goalNode.x && curr.y == goalNode.y && curr.z == goalNode.z && curr.world.equals(goalNode.world)) { + List path = new ArrayList<>(); + for (Node n = curr; n != null; n = n.parent) { + path.add(0, new Location(world, n.x + 0.5, n.y + 0.5, n.z + 0.5)); + } + return path; + } + if (closed.contains(curr) || curr.g > maxLength) continue; + closed.add(curr); + expanded++; + if (attempted != null) attempted.add(new Location(world, curr.x, curr.y, curr.z)); + for (Vector dir : getDirections(allowDiagonal)) { + int nx = curr.x + dir.getBlockX(); + int ny = curr.y + dir.getBlockY(); + int nz = curr.z + dir.getBlockZ(); + Location nextLoc = new Location(world, nx, ny, nz); + int dy = ny - curr.y; + if (Math.abs(dy) > maxStep) { + // Only allow if climbing and all blocks between are climbable + boolean canClimb = true; + int step = dy > 0 ? 1 : -1; + for (int ystep = curr.y + step; ystep != ny + step; ystep += step) { + Location climbLoc = new Location(world, nx, ystep, nz); + if (!isClimbable(climbLoc.getBlock().getType())) { + canClimb = false; + break; + } + } + if (!canClimb) continue; + } + if (!isWalkable(nextLoc.getBlock(), throughBlocks)) continue; + Node nextNode = new Node(nextLoc, curr, curr.g + 1, nextLoc.distance(goal)); + if (closed.contains(nextNode)) continue; + open.add(nextNode); + } + } + return null; + } + + private boolean isClimbable(org.bukkit.Material material) { + return org.bukkit.Tag.CLIMBABLE.isTagged(material); + } + + private List getDirections(boolean diagonal) { + List dirs = new ArrayList<>(); + for (int dx = -1; dx <= 1; dx++) + for (int dy = -1; dy <= 1; dy++) + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dy == 0 && dz == 0) continue; + if (!diagonal && Math.abs(dx) + Math.abs(dy) + Math.abs(dz) > 1) continue; + dirs.add(new Vector(dx, dy, dz)); + } + return dirs; + } + + private boolean isWalkable(Block block, boolean throughBlocks) { + if (throughBlocks) { + if (!block.isPassable()) return false; + if (!block.getRelative(0, 1, 0).isPassable()) return false; + + return matchesTraversalFilters(block); + } + + // Use the shared BlockUtils definition of a safe standable space to avoid + // duplicating passability and support checks. + Location feetLoc = block.getLocation().add(0.5, 0, 0.5); + if (!BlockUtils.isSafeToStand(feetLoc.clone())) return false; + + Block below = feetLoc.clone().subtract(0, 1, 0).getBlock(); + return matchesTraversalFilters(below); + } + + private Location findNearbyWalkable(Location loc, boolean throughBlocks) { + org.bukkit.World world = loc.getWorld(); + int bx = loc.getBlockX(); + int by = loc.getBlockY(); + int bz = loc.getBlockZ(); + + // Search a small vertical window around the requested location for a valid standable node. + for (int dy = -1; dy <= 2; dy++) { + int y = by + dy; + if (y < world.getMinHeight() || y > world.getMaxHeight()) continue; + + Block feetBlock = world.getBlockAt(bx, y, bz); + if (isWalkable(feetBlock, throughBlocks)) { + return new Location(world, feetBlock.getX() + 0.5, feetBlock.getY(), feetBlock.getZ() + 0.5); + } + } + + // Fallback: keep original location if nothing suitable is found. + return loc; + } + + private boolean shouldDisplayNode(Location loc, boolean throughBlocks) { + Block feetBlock = loc.getBlock(); + Block traversedBlock = throughBlocks ? feetBlock : feetBlock.getRelative(0, -1, 0); + + if (!matchesTraversalFilters(traversedBlock)) return false; + + if (throughBlocks) return true; + + // Without an allow-list, hide paths over air or water; show over normal solid blocks. + org.bukkit.Material mat = traversedBlock.getType(); + if (mat.isAir() || mat == org.bukkit.Material.WATER) return false; + + return true; + } + + private boolean matchesTraversalFilters(Block block) { + BlockData blockData = block.getBlockData(); + + if (deniedBlockData != null) { + for (BlockData bd : deniedBlockData) { + if (blockData.matches(bd)) return false; + } + } + + if (walkableBlockData != null) { + for (BlockData bd : walkableBlockData) { + if (blockData.matches(bd)) return true; + } + return false; + } + + return true; + } + + private Set parseBlockDataSet(List blockStrings) { + if (blockStrings == null) return null; + Set set = new HashSet<>(); + for (String s : blockStrings) { + try { set.add(Bukkit.createBlockData(s)); } + catch (IllegalArgumentException ignored) {} + } + return set.isEmpty() ? null : set; + } +}