From c890595ac750f3dafca5071b5b9c0471b22f8983 Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sat, 11 Apr 2026 01:10:48 -0400 Subject: [PATCH 1/8] Add ProxySpell class to implement buff spell redirection mechanics --- .../magicspells/spells/buff/ProxySpell.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java 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..362d84f15 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java @@ -0,0 +1,135 @@ +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 Map proxies; + private final Set redirecting; + + public ProxySpell(MagicConfig config, String spellName) { + super(config, spellName); + + proxies = new HashMap<>(); + redirecting = new HashSet<>(); + } + + @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; + + SpellData subData = event.getSpellData().target(proxyTarget); + playRedirectEffects(target, proxyTarget, subData); + + event.setTarget(proxyTarget); + 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) || !target.isValid()) 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); + } + +} From 40f702106716bbb97c7143be658d92cacea82d57 Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sat, 11 Apr 2026 01:11:06 -0400 Subject: [PATCH 2/8] Add PathfindSpell class to implement pathfinding mechanics for targeted spells --- .../spells/targeted/PathfindSpell.java | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java 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..4719c9063 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java @@ -0,0 +1,302 @@ +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 world = start.getWorld(); + 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; + } +} From 90e1960dd08f601558b43c288a1b103324f19412 Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sat, 11 Apr 2026 01:11:21 -0400 Subject: [PATCH 3/8] Add ScoreCondition --- .../conditions/ScoreCondition.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java 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..4eac0c1bb --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java @@ -0,0 +1,82 @@ +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.SpellData; +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) { + if (entity == null) return false; + Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + Objective obj = scoreboard.getObjective(objectiveName); + if (obj == null) return false; + int score = obj.getScore(entity.getName()).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; + } + } +} From 524ef0d6177cb932fb067913739938a0e717ae74 Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sat, 11 Apr 2026 01:11:45 -0400 Subject: [PATCH 4/8] Add BranchingProjectileSpell class to implement branching projectile mechanics --- .../instant/BranchingProjectileSpell.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java 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..62d4e8350 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java @@ -0,0 +1,155 @@ +package com.nisovin.magicspells.spells.instant; + +import com.nisovin.magicspells.util.MagicConfig; +import com.nisovin.magicspells.util.CastResult; + +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.entity.LivingEntity; +import org.bukkit.util.Vector; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitRunnable; + +import com.nisovin.magicspells.MagicSpells; +import com.nisovin.magicspells.Subspell; +import com.nisovin.magicspells.spells.InstantSpell; +import com.nisovin.magicspells.util.SpellData; +import com.nisovin.magicspells.util.config.ConfigData; +import com.nisovin.magicspells.spelleffects.EffectPosition; + +import java.util.*; + +public class BranchingProjectileSpell extends InstantSpell { + + private final ConfigData maxDistance; + private final ConfigData maxDuration; + private final ConfigData branchProbability; + private final ConfigData minBranchLength; + private final ConfigData maxBranchLength; + private final ConfigData branchAngle; + private final ConfigData stepLength; + private final ConfigData hitRadius; + private final ConfigData spellToCastName; + private Subspell spellToCast; + + public BranchingProjectileSpell(MagicConfig config, String spellName) { + super(config, spellName); + maxDistance = getConfigDataDouble("max-distance", 20.0); + maxDuration = getConfigDataInt("max-duration", 40); + branchProbability = getConfigDataDouble("branch-probability", 0.3); + minBranchLength = getConfigDataInt("min-branch-length", 3); + maxBranchLength = getConfigDataInt("max-branch-length", 8); + branchAngle = getConfigDataDouble("branch-angle", 30.0); + stepLength = getConfigDataDouble("step-length", 0.8); + hitRadius = getConfigDataDouble("hit-radius", 1.5); + 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 ? randomBetween(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 : getNearbyEntities(current, hitRadius.get(data))) { + if (entity.equals(data.caster())) continue; + SpellData hitData = data.target(entity).location(current); + if (spellToCast != null) spellToCast.subcast(hitData); + } + // 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(); + } + + private int randomBetween(int min, int max) { + return min + (int) (Math.random() * (max - min + 1)); + } + + private List getNearbyEntities(Location loc, double radius) { + List list = new ArrayList<>(); + for (org.bukkit.entity.Entity e : loc.getWorld().getNearbyEntities(loc, radius, radius, radius)) { + if (e instanceof LivingEntity le && le.isValid() && !le.isDead()) { + list.add(le); + } + } + return list; + } +} From 6e8b372df3fa418fef40796f1892af7e32a39475 Mon Sep 17 00:00:00 2001 From: Dragon Date: Sat, 11 Apr 2026 14:57:10 -0400 Subject: [PATCH 5/8] Update core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../magicspells/spells/instant/BranchingProjectileSpell.java | 2 -- 1 file changed, 2 deletions(-) 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 index 62d4e8350..9b2d49985 100644 --- a/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java @@ -4,10 +4,8 @@ import com.nisovin.magicspells.util.CastResult; import org.bukkit.Location; -import org.bukkit.Particle; import org.bukkit.entity.LivingEntity; import org.bukkit.util.Vector; -import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitRunnable; import com.nisovin.magicspells.MagicSpells; From 3a2e073fffe931a9c99fe506cafc823a80e8e33b Mon Sep 17 00:00:00 2001 From: Dragon Date: Sat, 11 Apr 2026 14:57:50 -0400 Subject: [PATCH 6/8] Update core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../magicspells/castmodifiers/conditions/ScoreCondition.java | 1 - 1 file changed, 1 deletion(-) 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 index 4eac0c1bb..93fab6600 100644 --- a/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java +++ b/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java @@ -8,7 +8,6 @@ import org.bukkit.scoreboard.Scoreboard; import com.nisovin.magicspells.castmodifiers.Condition; -import com.nisovin.magicspells.util.SpellData; import com.nisovin.magicspells.util.Name; /** From 31a92a8c574f1c25f2576e1c9265c5ba99379455 Mon Sep 17 00:00:00 2001 From: Dragon Date: Sat, 11 Apr 2026 14:58:46 -0400 Subject: [PATCH 7/8] Update core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../nisovin/magicspells/spells/targeted/PathfindSpell.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 4719c9063..42a459f5c 100644 --- a/core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java +++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/PathfindSpell.java @@ -155,7 +155,12 @@ class Node implements Comparable { } Set closed = new HashSet<>(); PriorityQueue open = new PriorityQueue<>(); - org.bukkit.World world = start.getWorld(); + 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); From 9f8ad177b5471e5232f5680503f3d9e52596cbb9 Mon Sep 17 00:00:00 2001 From: Dragon Date: Mon, 27 Apr 2026 01:43:45 -0400 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: JasperLorelai --- .../conditions/ScoreCondition.java | 3 +- .../magicspells/spells/buff/ProxySpell.java | 15 ++--- .../instant/BranchingProjectileSpell.java | 56 +++++++++---------- 3 files changed, 32 insertions(+), 42 deletions(-) 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 index 93fab6600..0c1bfc34c 100644 --- a/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java +++ b/core/src/main/java/com/nisovin/magicspells/castmodifiers/conditions/ScoreCondition.java @@ -59,11 +59,10 @@ public boolean check(LivingEntity caster, Location location) { } private boolean checkScore(LivingEntity entity) { - if (entity == null) return false; Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); Objective obj = scoreboard.getObjective(objectiveName); if (obj == null) return false; - int score = obj.getScore(entity.getName()).getScore(); + int score = obj.getScoreFor(entity).getScore(); return compare(score, operator, value); } 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 index 362d84f15..8805d6f2e 100644 --- a/core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java +++ b/core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java @@ -24,14 +24,12 @@ public class ProxySpell extends BuffSpell implements Listener { - private final Map proxies; - private final Set redirecting; + private final Set redirecting = new HashSet<>(); + private final Map proxies = new HashMap<>(); public ProxySpell(MagicConfig config, String spellName) { super(config, spellName); - - proxies = new HashMap<>(); - redirecting = new HashSet<>(); + } } @Override @@ -69,10 +67,9 @@ public void onSpellTarget(SpellTargetEvent event) { LivingEntity proxyTarget = getProxyTarget(target); if (proxyTarget == null) return; - SpellData subData = event.getSpellData().target(proxyTarget); - playRedirectEffects(target, proxyTarget, subData); - event.setTarget(proxyTarget); + playRedirectEffects(target, proxyTarget, event.getSpellData()); + addUseAndChargeCost(target); } @@ -93,7 +90,7 @@ public void onSpellPreImpact(SpellPreImpactEvent event) { @EventHandler(ignoreCancelled = true) public void onEntityDamage(EntityDamageByEntityEvent event) { - if (!(event.getEntity() instanceof LivingEntity target) || !target.isValid()) return; + if (!(event.getEntity() instanceof LivingEntity target)) return; LivingEntity proxyTarget = getProxyTarget(target); if (proxyTarget == null) return; 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 index 9b2d49985..afc8e1277 100644 --- a/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/BranchingProjectileSpell.java @@ -1,45 +1,50 @@ package com.nisovin.magicspells.spells.instant; -import com.nisovin.magicspells.util.MagicConfig; -import com.nisovin.magicspells.util.CastResult; +import java.util.*; import org.bukkit.Location; -import org.bukkit.entity.LivingEntity; import org.bukkit.util.Vector; +import org.bukkit.entity.LivingEntity; import org.bukkit.scheduler.BukkitRunnable; -import com.nisovin.magicspells.MagicSpells; import com.nisovin.magicspells.Subspell; -import com.nisovin.magicspells.spells.InstantSpell; +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; -import java.util.*; - public class BranchingProjectileSpell extends InstantSpell { private final ConfigData maxDistance; - private final ConfigData maxDuration; + 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 branchAngle; - private final ConfigData stepLength; - private final ConfigData hitRadius; + private final ConfigData spellToCastName; private Subspell spellToCast; public BranchingProjectileSpell(MagicConfig config, String spellName) { super(config, spellName); - maxDistance = getConfigDataDouble("max-distance", 20.0); + maxDuration = getConfigDataInt("max-duration", 40); - branchProbability = getConfigDataDouble("branch-probability", 0.3); + minBranchLength = getConfigDataInt("min-branch-length", 3); maxBranchLength = getConfigDataInt("max-branch-length", 8); - branchAngle = getConfigDataDouble("branch-angle", 30.0); - stepLength = getConfigDataDouble("step-length", 0.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", ""); } @@ -53,6 +58,7 @@ public void initialize() { 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); @@ -60,6 +66,7 @@ public CastResult cast(SpellData data) { } private class BranchTask extends BukkitRunnable { + private final Location current; private final Vector direction; private final double maxDist; @@ -80,7 +87,7 @@ private class BranchTask extends BukkitRunnable { this.data = data; this.branchDepth = branchDepth; this.isBranch = isBranch; - this.branchMaxLength = isBranch ? randomBetween(minBranchLength.get(data), maxBranchLength.get(data)) : Integer.MAX_VALUE; + this.branchMaxLength = isBranch ? random.nextInt(minBranchLength.get(data), maxBranchLength.get(data)) : Integer.MAX_VALUE; } @Override @@ -112,10 +119,9 @@ public void run() { EffectPosition pos = isBranch ? EffectPosition.SPECIAL : EffectPosition.PROJECTILE; playSpellEffects(pos, current, data.location(current)); // Hit detection - for (LivingEntity entity : getNearbyEntities(current, hitRadius.get(data))) { + for (LivingEntity entity : current.getNearbyLivingEntities(hitRadius.get(data))) { if (entity.equals(data.caster())) continue; - SpellData hitData = data.target(entity).location(current); - if (spellToCast != null) spellToCast.subcast(hitData); + if (spellToCast != null) spellToCast.subcast(data.retarget(entity, current)); } // Branching if (!isBranch && Math.random() < branchProbability.get(data)) { @@ -137,17 +143,5 @@ private Vector getBranchDirection(Vector base, double angleDeg) { return new Vector(x, y, z).normalize(); } - private int randomBetween(int min, int max) { - return min + (int) (Math.random() * (max - min + 1)); - } - private List getNearbyEntities(Location loc, double radius) { - List list = new ArrayList<>(); - for (org.bukkit.entity.Entity e : loc.getWorld().getNearbyEntities(loc, radius, radius, radius)) { - if (e instanceof LivingEntity le && le.isValid() && !le.isDead()) { - list.add(le); - } - } - return list; - } }