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 f1f76490c584444fc5105183e0e0050635f76369 Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sun, 7 Jun 2026 00:34:10 -0400 Subject: [PATCH 5/8] Add DataLocation class to provide location-based data functions --- .../spells/targeted/DataSpell.java | 43 +++++++++++------- .../magicspells/util/data/DataLocation.java | 44 +++++++++++++++++++ 2 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 core/src/main/java/com/nisovin/magicspells/util/data/DataLocation.java diff --git a/core/src/main/java/com/nisovin/magicspells/spells/targeted/DataSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/targeted/DataSpell.java index 33f9cb2bb..d4b7e59cc 100644 --- a/core/src/main/java/com/nisovin/magicspells/spells/targeted/DataSpell.java +++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/DataSpell.java @@ -2,6 +2,7 @@ import java.util.function.Function; +import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.entity.LivingEntity; @@ -11,39 +12,53 @@ import com.nisovin.magicspells.spells.TargetedSpell; import com.nisovin.magicspells.util.config.ConfigData; import com.nisovin.magicspells.spells.TargetedEntitySpell; +import com.nisovin.magicspells.spells.TargetedLocationSpell; +import com.nisovin.magicspells.util.data.DataLocation; import com.nisovin.magicspells.util.data.DataLivingEntity; import com.nisovin.magicspells.variables.variabletypes.GlobalVariable; import com.nisovin.magicspells.variables.variabletypes.GlobalStringVariable; -public class DataSpell extends TargetedSpell implements TargetedEntitySpell { +public class DataSpell extends TargetedSpell implements TargetedEntitySpell, TargetedLocationSpell { - private final ConfigData> dataElement; + private final ConfigData dataElement; private final ConfigData variableName; public DataSpell(MagicConfig config, String spellName) { super(config, spellName); variableName = getConfigDataString("variable-name", ""); - - ConfigData supplier = getConfigDataString("data-element", "uuid"); - if (supplier.isConstant()) { - Function function = DataLivingEntity.getDataFunction(supplier.get()); - dataElement = data -> function; - } else { - dataElement = data -> DataLivingEntity.getDataFunction(supplier.get(data)); - } + dataElement = getConfigDataString("data-element", "uuid"); } @Override public CastResult cast(SpellData data) { TargetInfo info = getTargetedEntity(data); - if (info.noTarget()) return noTarget(info); + if (info.cancelled()) return noTarget(info); + if (!info.empty()) return castAtEntity(info.spellData()); + + TargetInfo locationInfo = getTargetedBlockLocation(data); + if (locationInfo.noTarget()) return noTarget(locationInfo); - return castAtEntity(info.spellData()); + return castAtLocation(locationInfo.spellData()); } @Override public CastResult castAtEntity(SpellData data) { + Function dataElement = DataLivingEntity.getDataFunction(this.dataElement.get(data)); + if (dataElement == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + return applyValue(data, dataElement.apply(data.target())); + } + + @Override + public CastResult castAtLocation(SpellData data) { + Function dataElement = DataLocation.getDataFunction(this.dataElement.get(data)); + if (dataElement == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + return applyValue(data, dataElement.apply(data.location())); + } + + private CastResult applyValue(SpellData data, String value) { Variable variable = MagicSpells.getVariableManager().getVariable(variableName.get(data)); if (variable == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data); @@ -51,10 +66,6 @@ public CastResult castAtEntity(SpellData data) { if (caster == null && !(variable instanceof GlobalVariable) && !(variable instanceof GlobalStringVariable)) return new CastResult(PostCastAction.ALREADY_HANDLED, data); - Function dataElement = this.dataElement.get(data); - if (dataElement == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data); - - String value = dataElement.apply(data.target()); MagicSpells.getVariableManager().set(variable, caster == null ? null : caster.getName(), value); playSpellEffects(data); diff --git a/core/src/main/java/com/nisovin/magicspells/util/data/DataLocation.java b/core/src/main/java/com/nisovin/magicspells/util/data/DataLocation.java new file mode 100644 index 000000000..96b80f5d7 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/util/data/DataLocation.java @@ -0,0 +1,44 @@ +package com.nisovin.magicspells.util.data; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.bukkit.Location; +import org.bukkit.block.Block; + +public class DataLocation { + + private static final Map> dataElements = new HashMap<>(); + + static { + dataElements.put("location.biome", location -> location.getBlock().getBiome().toString()); + dataElements.put("location.block.data", DataLocation::blockData); + dataElements.put("location.block.type", location -> location.getBlock().getType().name()); + dataElements.put("location.elevation", location -> location.getWorld().getHighestBlockYAt(location) + ""); + dataElements.put("location.light", location -> location.getBlock().getLightLevel() + ""); + dataElements.put("location.light.block", location -> location.getBlock().getLightFromBlocks() + ""); + dataElements.put("location.light.sky", location -> location.getBlock().getLightFromSky() + ""); + dataElements.put("location", Location::toString); + dataElements.put("location.blockx", location -> location.getBlockX() + ""); + dataElements.put("location.blocky", location -> location.getBlockY() + ""); + dataElements.put("location.blockz", location -> location.getBlockZ() + ""); + dataElements.put("location.pitch", location -> location.getPitch() + ""); + dataElements.put("location.x", location -> location.getX() + ""); + dataElements.put("location.y", location -> location.getY() + ""); + dataElements.put("location.yaw", location -> location.getYaw() + ""); + dataElements.put("location.z", location -> location.getZ() + ""); + dataElements.put("world", location -> location.getWorld().toString()); + dataElements.put("world.name", location -> location.getWorld().getName()); + } + + private static String blockData(Location location) { + Block block = location.getBlock(); + return block.getBlockData().getAsString(); + } + + public static Function getDataFunction(String elementId) { + return dataElements.get(elementId); + } + +} \ No newline at end of file From b2c2f75c85f829d1074d49c4552d4c1843c6fbab Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sun, 7 Jun 2026 00:35:40 -0400 Subject: [PATCH 6/8] Add ScryingSpell, StructureLocatorSpell, and WaveSpell --- .../spells/instant/ScryingSpell.java | 310 ++++++++++++++++++ .../spells/instant/StructureLocatorSpell.java | 62 ++++ .../magicspells/spells/instant/WaveSpell.java | 156 +++++++++ .../spells/targeted/PathfindSpell.java | 67 +++- .../spells/targeted/SpawnEntitySpell.java | 92 +++--- 5 files changed, 633 insertions(+), 54 deletions(-) create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/instant/ScryingSpell.java create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/instant/StructureLocatorSpell.java create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/instant/WaveSpell.java diff --git a/core/src/main/java/com/nisovin/magicspells/spells/instant/ScryingSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/instant/ScryingSpell.java new file mode 100644 index 000000000..1ae9b7b20 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/ScryingSpell.java @@ -0,0 +1,310 @@ +package com.nisovin.magicspells.spells.instant; + +import java.util.*; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.BlockDisplay; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.util.Transformation; +import org.bukkit.util.Vector; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import me.libraryaddict.disguise.DisguiseAPI; +import me.libraryaddict.disguise.disguisetypes.Disguise; + +import com.nisovin.magicspells.util.*; +import com.nisovin.magicspells.MagicSpells; +import com.nisovin.magicspells.spells.TargetedSpell; +import com.nisovin.magicspells.util.config.ConfigData; +import com.nisovin.magicspells.spells.TargetedEntitySpell; + +public class ScryingSpell extends TargetedSpell implements TargetedEntitySpell { + private final ConfigData scryText; + private final ConfigData areaSize; + private final ConfigData scale; + private final ConfigData period; + private final ConfigData iterations; + private final ConfigData durationTicks; + private final ConfigData relativeOffset; + private final ConfigData snapToBlockCenter; + + public ScryingSpell(MagicConfig config, String spellName) { + super(config, spellName); + areaSize = getConfigDataInt("area-size", 10); + scale = getConfigDataDouble("scale", 0.2); + period = getConfigDataInt("period", 20); // ticks + iterations = getConfigDataInt("iterations", 5); + durationTicks = getConfigDataInt("duration-ticks", 100); + relativeOffset = getConfigDataVector("relative-offset", new Vector(0, 0, 0)); + snapToBlockCenter = getConfigDataBoolean("snap-to-block-center", true); + scryText = getConfigDataString("scry-text", null); + } + + @Override + public CastResult cast(SpellData data) { + TargetInfo info = getTargetedEntity(data); + if (info.noTarget()) return noTarget(info); + + return castAtEntity(info.spellData()); + } + + @Override + public CastResult castAtEntity(SpellData data) { + if (!data.hasCaster() || !data.hasTarget()) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + LivingEntity caster = data.caster(); + LivingEntity target = data.target(); + Location visionOrigin = caster.getLocation().add(caster.getLocation().getDirection().multiply(3)); + // Set pitch and yaw to zero to avoid unintended rotation + visionOrigin.setPitch(0f); + visionOrigin.setYaw(0f); + int area = areaSize.get(data); + double scl = scale.get(data); + int periodTicks = period.get(data); + int maxIterations = iterations.get(data); + int visionDuration = durationTicks.get(data); + Vector relOffset = relativeOffset.get(data); + boolean snap = snapToBlockCenter.get(data); + String text = scryText.get(data); + + // Snap visionOrigin to the center of the block if enabled + if (snap) { + visionOrigin.setX(visionOrigin.getBlockX() + 0.5); + visionOrigin.setZ(visionOrigin.getBlockZ() + 0.5); + } + // Align the bottom of the minimap with the targeted block + // The minimap is centered on visionOrigin, so shift it UP by half the area (scaled) + visionOrigin.setY(visionOrigin.getY() + (area * scl) / 2.0); + + // If targeting self, treat as minimap (show surroundings) + LivingEntity visionTarget = (target.equals(caster)) ? caster : target; + ScryingVision vision = new ScryingVision(visionTarget, visionOrigin, area, scl, visionDuration, periodTicks, maxIterations, relOffset, text); + vision.start(); + return new CastResult(PostCastAction.HANDLE_NORMALLY, data); + } + + private static class ScryingVision implements Runnable { + // Helper to check if a block is transparent or a fluid + private boolean isTransparentOrFluid(Material mat) { + if (mat.isAir() || mat.isTransparent() || !mat.isOccluding()) return true; + + String name = mat.name(); + return name.contains("WATER") || name.contains("LAVA"); + } + + private Entity scryedTextDisplay = null; + /** + * Spawns a scaled-down copy of the target entity at the given location. + * If the entity is a mob, spawns the same mob type, disables AI, scales it down, and tags it. + * If not, spawns an ArmorStand as fallback. + */ + + private Entity spawnScryedEntity(Location loc, double scale) { + World world = loc.getWorld(); + Entity spawnedEntity = null; + try { + // Try to spawn the same type if it's a mob + if (target instanceof org.bukkit.entity.Mob) { + org.bukkit.entity.Mob mob = (org.bukkit.entity.Mob) target; + spawnedEntity = world.spawnEntity(loc, mob.getType()); + if (spawnedEntity instanceof org.bukkit.entity.Mob) { + org.bukkit.entity.Mob spawnedMob = (org.bukkit.entity.Mob) spawnedEntity; + spawnedMob.setAI(false); + spawnedMob.setSilent(true); + spawnedMob.setCustomName(target.getName()); + spawnedMob.setCustomNameVisible(true); + spawnedMob.addScoreboardTag("MS_ENTITY"); + spawnedMob.addScoreboardTag("SCRYING_SPAWN"); + // Try to scale down (if supported) + try { + spawnedMob.getAttribute(org.bukkit.attribute.Attribute.valueOf("SCALE")).setBaseValue(scale); + } catch (Throwable ignored) {} + } + } else { + // Fallback: ArmorStand + ArmorStand stand = world.spawn(loc, ArmorStand.class, e -> { + e.setVisible(true); + e.setCustomName(target.getName()); + e.setCustomNameVisible(true); + e.setAI(false); + e.setGravity(false); + e.setSmall(true); + e.addScoreboardTag("SCRYING_SPAWN"); + e.setHelmet(target instanceof LivingEntity && ((LivingEntity)target).getEquipment() != null ? ((LivingEntity)target).getEquipment().getHelmet() : null); + try { + e.getAttribute(org.bukkit.attribute.Attribute.valueOf("SCALE")).setBaseValue(scale); + } catch (Throwable ignored) {} + }); + spawnedEntity = stand; + } + } catch (Throwable t) { + // Fallback: ArmorStand if anything fails + ArmorStand stand = world.spawn(loc, ArmorStand.class, e -> { + e.setVisible(true); + e.setCustomName(target.getName()); + e.setCustomNameVisible(true); + e.setAI(false); + e.setGravity(false); + e.setSmall(true); + e.setMarker(true); // No hitbox + e.addScoreboardTag("SCRYING_SPAWN"); + e.addScoreboardTag("MS_ENTITY"); + e.setHelmet(target instanceof LivingEntity && ((LivingEntity)target).getEquipment() != null ? ((LivingEntity)target).getEquipment().getHelmet() : null); + try { + e.getAttribute(org.bukkit.attribute.Attribute.valueOf("SCALE")).setBaseValue(scale); + } catch (Throwable ignored) {} + }); + spawnedEntity = stand; + } + return spawnedEntity; + } + private final LivingEntity target; + private final Location visionOrigin; + private final int areaSize; + private final double scale; + private final int visionDuration; + private final int periodTicks; + private final int maxIterations; + private int iteration = 0; + private final List spawned = new ArrayList<>(); + private Entity scryedEntity = null; + private int taskId = -1; + + private final Vector relOffset; + private final String scryText; + + public ScryingVision(LivingEntity target, Location visionOrigin, int areaSize, double scale, int visionDuration, int periodTicks, int maxIterations, Vector relOffset, String scryText) { + this.target = target; + this.visionOrigin = visionOrigin.clone(); + this.areaSize = areaSize; + this.scale = scale; + this.visionDuration = visionDuration; + this.periodTicks = periodTicks; + this.maxIterations = maxIterations; + this.relOffset = relOffset; + this.scryText = scryText; + } + + public void start() { + updateVision(); + spawnScryedEntityOnTopBlock(); + if (maxIterations > 1) { + taskId = MagicSpells.scheduleRepeatingTask(this, periodTicks, periodTicks); + } + } + + @Override + public void run() { + iteration++; + clearVision(); + updateVision(); + // Do not respawn scryed entity + if (iteration >= maxIterations - 1) { + MagicSpells.scheduleDelayedTask(this::clearVision, visionDuration); + MagicSpells.cancelTask(taskId); + } + } + + private int highestCenterY = Integer.MIN_VALUE; + private void updateVision() { + // Block face offsets + int[][] faces = { {1,0,0}, {-1,0,0}, {0,1,0}, {0,-1,0}, {0,0,1}, {0,0,-1} }; + Location targetLoc = target.getLocation().clone().add(relOffset); + World world = targetLoc.getWorld(); + int half = areaSize / 2; + highestCenterY = Integer.MIN_VALUE; + // Copy all blocks from ground up in the area + for (int x = -half; x < half; x++) { + for (int z = -half; z < half; z++) { + for (int y = -half; y < half; y++) { + Location blockLoc = targetLoc.clone().add(x, y, z); + Material mat = blockLoc.getBlock().getType(); + if (mat.isAir()) continue; + boolean hasOpenFace = false; + for (int[] face : faces) { + Location neighbor = blockLoc.clone().add(face[0], face[1], face[2]); + Material neighborMat = neighbor.getBlock().getType(); + if (isTransparentOrFluid(neighborMat)) { + hasOpenFace = true; + break; + } + } + if (!hasOpenFace) continue; + Vector offset = new Vector(x * scale, y * scale, z * scale); + Location displayLoc = visionOrigin.clone().add(offset); + BlockDisplay display = world.spawn(displayLoc, BlockDisplay.class, e -> { + e.setBlock(blockLoc.getBlock().getBlockData()); + e.setTransformation(new Transformation( + new Vector3f(0,0,0), + new Quaternionf(0,0,0,1), + new Vector3f((float)scale, (float)scale, (float)scale), + new Quaternionf(0,0,0,1) + )); + }); + spawned.add(display); + // Make this block display last 1 tick longer to prevent flicker + MagicSpells.scheduleDelayedTask(display::remove, periodTicks + 2); + // Track the highest Y at the center (x==0, z==0) + if (x == 0 && z == 0 && y > highestCenterY) { + highestCenterY = y; + } + } + } + } + } + + private void clearVision() { + for (Entity e : spawned) e.remove(); + spawned.clear(); + // Only remove the scryed entity when vision ends (not every update) + if (iteration >= maxIterations - 1 && scryedEntity != null) { + scryedEntity.remove(); + scryedEntity = null; + } + if (iteration >= maxIterations - 1 && scryedTextDisplay != null) { + scryedTextDisplay.remove(); + scryedTextDisplay = null; + } + } + /** + * Spawns the scryed entity once, on the top block at the minimap's center. + * Also spawns a scaled text display above it if configured. + */ + private void spawnScryedEntityOnTopBlock() { + Location centerLoc = visionOrigin.clone(); + World world = centerLoc.getWorld(); + // Place entity 0.1 above the highest block display at the center of the minimap + double y = visionOrigin.getY() + (highestCenterY * scale) + 0.1; + centerLoc.setY(y); + scryedEntity = spawnScryedEntity(centerLoc, scale); + // Disguise the entity as the target using LibsDisguises + try { + Disguise disguise = DisguiseAPI.getDisguise(target); + if (disguise != null) { + DisguiseAPI.disguiseEntity(scryedEntity, disguise); + } + } catch (Throwable ignored) {} + + // Spawn a scaled text display above the scryed entity if configured + if (scryText != null && !scryText.isEmpty()) { + Location textLoc = centerLoc.clone().add(0, 0.7 * scale + 0.5, 0); + try { + org.bukkit.entity.TextDisplay display = world.spawn(textLoc, org.bukkit.entity.TextDisplay.class, td -> { + td.setText(scryText); + td.setBillboard(org.bukkit.entity.TextDisplay.Billboard.CENTER); + td.setSeeThrough(true); + td.setBackgroundColor(org.bukkit.Color.fromARGB(0, 0, 0, 0)); + td.setShadowed(true); + td.setLineWidth(80); + }); + scryedTextDisplay = display; + } catch (Throwable ignored) {} + } + } + } +} diff --git a/core/src/main/java/com/nisovin/magicspells/spells/instant/StructureLocatorSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/instant/StructureLocatorSpell.java new file mode 100644 index 000000000..fb728109f --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/StructureLocatorSpell.java @@ -0,0 +1,62 @@ +package com.nisovin.magicspells.spells.instant; + +import com.nisovin.magicspells.util.MagicConfig; +import com.nisovin.magicspells.util.CastResult; +import com.nisovin.magicspells.spells.InstantSpell; +import com.nisovin.magicspells.util.SpellData; +import com.nisovin.magicspells.Spell.PostCastAction; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.generator.structure.StructureType; +import org.bukkit.NamespacedKey; + +import com.nisovin.magicspells.util.config.ConfigData; + +public class StructureLocatorSpell extends InstantSpell { + + private final ConfigData searchRadius; + + public StructureLocatorSpell(MagicConfig config, String spellName) { + super(config, spellName); + searchRadius = getConfigDataInt("search-radius", 10000); + } + + @Override + public CastResult cast(SpellData data) { + Player player = data.caster() instanceof Player ? (Player) data.caster() : null; + if (player == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + String structureName = (data.args() != null && data.args().length > 0) ? data.args()[0] : null; + if (structureName == null || structureName.isEmpty()) { + player.sendMessage("§cNo structure specified."); + return new CastResult(PostCastAction.ALREADY_HANDLED, data); + } + + World world = player.getWorld(); + StructureType structureType = null; + try { + NamespacedKey key = NamespacedKey.fromString(structureName); + if (key != null) { + structureType = Bukkit.getRegistry(StructureType.class).get(key); + } + } catch (Exception ignored) {} + + if (structureType == null) { + player.sendMessage("§cUnknown structure: " + structureName); + return new CastResult(PostCastAction.ALREADY_HANDLED, data); + } + + int radius = searchRadius.get(data); + var result = world.locateNearestStructure(player.getLocation(), structureType, radius, false); + Location found = (result != null) ? result.getLocation() : null; + if (found != null) { + player.sendMessage("§aNearest " + structureName + ": §e" + found.getBlockX() + ", " + found.getBlockY() + ", " + found.getBlockZ()); + } else { + player.sendMessage("§cNo structure found within search radius."); + } + return new CastResult(PostCastAction.HANDLE_NORMALLY, data); + } +} diff --git a/core/src/main/java/com/nisovin/magicspells/spells/instant/WaveSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/instant/WaveSpell.java new file mode 100644 index 000000000..4d01f1741 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/WaveSpell.java @@ -0,0 +1,156 @@ +package com.nisovin.magicspells.spells.instant; + +import java.util.HashSet; +import java.util.Set; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.util.Vector; + +import com.nisovin.magicspells.util.*; +import com.nisovin.magicspells.MagicSpells; +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; + +public class WaveSpell extends TargetedSpell implements TargetedLocationSpell { + + private final ConfigData radius; + private final ConfigData startRadius; + private final ConfigData expandInterval; + private final ConfigData expandingRadiusChange; + private final ConfigData visibleRange; + private final ConfigData coneAngle; + private final ConfigData hugSurface; + private final ConfigData relativeOffset; + private final ConfigData fakeBlocks; + private final ConfigData waveMaterial; + + private final String spellOnEndName; + private final String locationSpellName; + private Subspell spellOnEnd; + private Subspell locationSpell; + + public WaveSpell(MagicConfig config, String spellName) { + super(config, spellName); + radius = getConfigDataInt("radius", 8); + startRadius = getConfigDataInt("start-radius", 0); + expandInterval = getConfigDataInt("expand-interval", 3); + expandingRadiusChange = getConfigDataInt("expanding-radius-change", 1); + visibleRange = getConfigDataDouble("visible-range", 20); + coneAngle = getConfigDataDouble("cone-angle", 90.0); // degrees + hugSurface = getConfigDataBoolean("hug-surface", true); + relativeOffset = getConfigDataVector("relative-offset", new Vector()); + fakeBlocks = getConfigDataBoolean("fake-blocks", false); + waveMaterial = getConfigDataMaterial("wave-material", Material.WATER); + spellOnEndName = getConfigString("spell-on-end", ""); + locationSpellName = getConfigString("spell", ""); + } + + @Override + public void initialize() { + super.initialize(); + String error = "WaveSpell '" + internalName + "' has an invalid '%s' defined!"; + locationSpell = initSubspell(locationSpellName, error.formatted("spell"), true); + spellOnEnd = initSubspell(spellOnEndName, error.formatted("spell-on-end"), true); + } + + @Override + public CastResult cast(SpellData data) { + TargetInfo info = getTargetedBlockLocation(data); + if (info.noTarget()) return noTarget(info); + + return castAtLocation(info.spellData()); + } + + @Override + public CastResult castAtLocation(SpellData data) { + new WaveTracker(data); + playSpellEffects(data); + return new CastResult(PostCastAction.HANDLE_NORMALLY, data); + } + + private class WaveTracker implements Runnable { + private final SpellData data; + private final Location center; + private final Set affected; + private final int taskId; + private int currentRadius; + private int count; + private final int maxRadius; + private final int expandingRadiusChange; + private final double coneAngleRad; + private final double visibleRange; + private final boolean hugSurface; + private final Vector facingDir; + + public WaveTracker(SpellData data) { + this.data = data.noTarget(); + this.center = data.location().clone(); + Vector rel = WaveSpell.this.relativeOffset.get(data); + center.add(0, rel.getY(), 0); + Util.applyRelativeOffset(center, rel.setY(0)); + this.affected = new HashSet<>(); + this.currentRadius = WaveSpell.this.startRadius.get(data); + this.count = 0; + this.maxRadius = WaveSpell.this.radius.get(data); + this.expandingRadiusChange = WaveSpell.this.expandingRadiusChange.get(data); + this.coneAngleRad = Math.toRadians(WaveSpell.this.coneAngle.get(data)); + this.visibleRange = WaveSpell.this.visibleRange.get(data); + this.hugSurface = WaveSpell.this.hugSurface.get(data); + this.facingDir = center.getDirection().normalize(); + this.taskId = MagicSpells.scheduleRepeatingTask(this, 0, WaveSpell.this.expandInterval.get(data)); + } + + @Override + public void run() { + currentRadius += expandingRadiusChange; + if (currentRadius > maxRadius) { + stop(); + return; + } + // For each point in the current ring + int points = currentRadius * 16; + double angleStep = coneAngleRad / points; + double startAngle = -coneAngleRad / 2; + for (int i = 0; i < points; i++) { + double angle = startAngle + i * angleStep; + Vector dir = facingDir.clone().rotateAroundY(angle); + Location loc = center.clone().add(dir.multiply(currentRadius)); + if (hugSurface) { + loc = findSurfaceBelow(loc, 8); + } + if (affected.contains(loc.getBlock().getLocation())) continue; + affected.add(loc.getBlock().getLocation()); + // Play effects and/or set block + playSpellEffects(EffectPosition.SPECIAL, loc, data); + if (locationSpell != null) locationSpell.subcast(data.location(loc)); + if (fakeBlocks.get(data)) { + for (org.bukkit.entity.Player player : center.getNearbyPlayers(visibleRange)) { + player.sendBlockChange(loc, waveMaterial.get(data).createBlockData()); + } + } + } + } + + private Location findSurfaceBelow(Location loc, int maxDown) { + Location check = loc.clone(); + for (int i = 0; i < maxDown; i++) { + Block block = check.getBlock(); + if (!block.isPassable()) { + return block.getLocation().add(0, 1, 0); + } + check.subtract(0, 1, 0); + } + return loc; + } + + private void stop() { + if (spellOnEnd != null) spellOnEnd.subcast(data); + MagicSpells.cancelTask(taskId); + } + } +} 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..42fe7a9ce 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 @@ -93,10 +93,8 @@ public CastResult castAtEntity(SpellData 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); + Location start = getPathNodeLocation(from, throughBlocks); + Location goal = getPathNodeLocation(to, throughBlocks); boolean diagonal = allowDiagonal.get(data); Integer maxStepValue = maxStepHeight.get(data); @@ -107,7 +105,8 @@ private CastResult castPath(Location from, Location to, SpellData data) { 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))); + Location effectLocation = getEffectLocation(loc, throughBlocks); + playSpellEffects(EffectPosition.DISABLED, effectLocation, data.location(effectLocation)); } return noTarget(data); } @@ -166,7 +165,7 @@ class Node implements Comparable { 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)); + path.add(0, getEffectLocation(new Location(world, n.x, n.y, n.z), throughBlocks)); } return path; } @@ -220,10 +219,7 @@ private List getDirections(boolean diagonal) { 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); + return matchesTraversalFilters(block, true); } // Use the shared BlockUtils definition of a safe standable space to avoid @@ -232,10 +228,45 @@ private boolean isWalkable(Block block, boolean throughBlocks) { if (!BlockUtils.isSafeToStand(feetLoc.clone())) return false; Block below = feetLoc.clone().subtract(0, 1, 0).getBlock(); - return matchesTraversalFilters(below); + return matchesTraversalFilters(below, false); } - private Location findNearbyWalkable(Location loc, boolean throughBlocks) { + private Location getPathNodeLocation(Location loc, boolean throughBlocks) { + if (throughBlocks) { + Block block = loc.getBlock(); + if (isWalkable(block, true)) { + return new Location(block.getWorld(), block.getX(), block.getY(), block.getZ()); + } + + org.bukkit.World world = block.getWorld(); + int bx = block.getX(); + int by = block.getY(); + int bz = block.getZ(); + + for (int distance = 1; distance <= 2; distance++) { + for (int dx = -distance; dx <= distance; dx++) { + for (int dy = -distance; dy <= distance; dy++) { + for (int dz = -distance; dz <= distance; dz++) { + int manhattan = Math.abs(dx) + Math.abs(dy) + Math.abs(dz); + if (manhattan == 0 || manhattan > distance) continue; + + int x = bx + dx; + int y = by + dy; + int z = bz + dz; + if (y < world.getMinHeight() || y > world.getMaxHeight()) continue; + + Block nearbyBlock = world.getBlockAt(x, y, z); + if (!isWalkable(nearbyBlock, true)) continue; + + return new Location(world, x, y, z); + } + } + } + } + + return new Location(block.getWorld(), block.getX(), block.getY(), block.getZ()); + } + org.bukkit.World world = loc.getWorld(); int bx = loc.getBlockX(); int by = loc.getBlockY(); @@ -248,7 +279,7 @@ private Location findNearbyWalkable(Location loc, boolean throughBlocks) { 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); + return new Location(world, feetBlock.getX(), feetBlock.getY(), feetBlock.getZ()); } } @@ -256,11 +287,15 @@ private Location findNearbyWalkable(Location loc, boolean throughBlocks) { return loc; } + private Location getEffectLocation(Location loc, boolean throughBlocks) { + return new Location(loc.getWorld(), loc.getBlockX() + 0.5, loc.getBlockY() + 0.5, loc.getBlockZ() + 0.5); + } + 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 (!matchesTraversalFilters(traversedBlock, throughBlocks)) return false; if (throughBlocks) return true; @@ -271,7 +306,7 @@ private boolean shouldDisplayNode(Location loc, boolean throughBlocks) { return true; } - private boolean matchesTraversalFilters(Block block) { + private boolean matchesTraversalFilters(Block block, boolean throughBlocks) { BlockData blockData = block.getBlockData(); if (deniedBlockData != null) { @@ -287,6 +322,8 @@ private boolean matchesTraversalFilters(Block block) { return false; } + if (throughBlocks) return !block.getType().isAir(); + return true; } diff --git a/core/src/main/java/com/nisovin/magicspells/spells/targeted/SpawnEntitySpell.java b/core/src/main/java/com/nisovin/magicspells/spells/targeted/SpawnEntitySpell.java index a93aa579c..f557ea18f 100644 --- a/core/src/main/java/com/nisovin/magicspells/spells/targeted/SpawnEntitySpell.java +++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/SpawnEntitySpell.java @@ -1,6 +1,12 @@ package com.nisovin.magicspells.spells.targeted; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.UUID; import com.google.common.collect.Multimap; @@ -19,7 +25,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; -import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.potion.PotionEffectType; import org.bukkit.inventory.EntityEquipment; @@ -269,19 +275,15 @@ public void turnOff() { @Override public CastResult cast(SpellData data) { - String spawnLocation = this.location.get(data).toLowerCase(); + String spawnLocation = this.location.get(data).toLowerCase(Locale.ROOT); if (spawnLocation.startsWith("casteroffset:")) { - String[] split = spawnLocation.split(":", 2); - - float y; - try { - y = Float.parseFloat(split[1]); - } catch (NumberFormatException e) { + Vector offset = parseOffset(spawnLocation, "casteroffset:"); + if (offset == null) { return new CastResult(PostCastAction.ALREADY_HANDLED, data); } - Location location = data.caster().getLocation().add(0, y, 0); + Location location = data.caster().getLocation().add(offset); location.setPitch(0); SpellTargetLocationEvent targetEvent = new SpellTargetLocationEvent(this, data, location); @@ -294,21 +296,6 @@ public CastResult cast(SpellData data) { if (!targetEvent.callEvent()) return noTarget(targetEvent); data = targetEvent.getSpellData(); } - case "target" -> { - RayTraceResult result = rayTraceBlocks(data); - if (result == null) return noTarget(data); - - Block block = result.getHitBlock(); - if (!block.isPassable()) { - Block upper = block.getRelative(BlockFace.UP); - if (!upper.isPassable()) return noTarget(data); - block = upper; - } - - SpellTargetLocationEvent targetEvent = new SpellTargetLocationEvent(this, data, block.getLocation()); - if (!targetEvent.callEvent()) return noTarget(targetEvent); - data = targetEvent.getSpellData(); - } case "focus" -> { SpellTargetLocationEvent targetEvent = new SpellTargetLocationEvent(this, data, getRandomLocationFrom(data.caster().getLocation(), data, 3)); if (!targetEvent.callEvent()) return noTarget(targetEvent); @@ -323,15 +310,27 @@ public CastResult cast(SpellData data) { if (!targetEvent.callEvent()) return noTarget(targetEvent); data = targetEvent.getSpellData(); } + case "target" -> { + TargetInfo info = getTargetedBlockLocation(data); + if (info.noTarget()) return noTarget(info); + data = info.spellData(); + } default -> { - return new CastResult(PostCastAction.ALREADY_HANDLED, data); + if (!spawnLocation.startsWith("offset:")) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + TargetInfo info = getTargetedBlockLocation(data); + if (info.noTarget()) return noTarget(info); + data = info.spellData(); } } } if (!data.hasLocation()) return noTarget(data); - return spawnMob(data.caster().getLocation(), data); + Location source = data.hasCaster() ? data.caster().getLocation() : data.location(); + return spawnLocation.startsWith("offset:") || spawnLocation.equals("target") + ? castAtLocation(data) + : spawnMob(source, data); } @Override @@ -347,17 +346,12 @@ public CastResult castAtEntityFromLocation(SpellData data) { private CastResult castAtSpawnLocation(SpellData data, String spawnLocation) { if (spawnLocation.startsWith("offset:")) { - String[] split = spawnLocation.split(":", 2); - - float y; - try { - y = Float.parseFloat(split[1]); - } catch (NumberFormatException e) { + Vector offset = parseOffset(spawnLocation, "offset:"); + if (offset == null) { return new CastResult(PostCastAction.ALREADY_HANDLED, data); } - Location location = data.location().add(0, y, 0); - location.setPitch(0); + Location location = data.location().clone().add(offset); Location source = data.hasCaster() ? data.caster().getLocation() : data.location(); data = data.location(location); @@ -391,6 +385,25 @@ private CastResult castAtSpawnLocation(SpellData data, String spawnLocation) { }; } + private Vector parseOffset(String spawnLocation, String prefix) { + String value = spawnLocation.substring(prefix.length()).trim(); + String[] split = value.split(","); + + try { + if (split.length == 1) return new Vector(0, Double.parseDouble(split[0].trim()), 0); + if (split.length == 3) { + double x = Double.parseDouble(split[0].trim()); + double y = Double.parseDouble(split[1].trim()); + double z = Double.parseDouble(split[2].trim()); + return new Vector(x, y, z); + } + } catch (NumberFormatException e) { + return null; + } + + return null; + } + private Location getRandomLocationFrom(Location location, SpellData data, int range) { if (range <= 0) return location; World world = location.getWorld(); @@ -439,10 +452,11 @@ private CastResult spawnMob(Location source, SpellData data) { } SpellData finalData = data; - Entity entity = entityData.spawn(loc, data, - mob -> prepMob(mob, finalData), - mob -> mob.setPersistent(!removeMob) - ); + Entity entity = entityData.spawn(loc, data, mob -> prepMob(mob, finalData), mob -> { + if (!removeMob) return; + mob.setPersistent(false); + Util.forEachPassenger(mob, e -> e.setPersistent(false)); + }); if (entity == null) return noTarget(data); UUID uuid = entity.getUniqueId(); From 04b701ea60cd1b70c845611af39c5a5246a3e0aa Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sun, 7 Jun 2026 00:37:04 -0400 Subject: [PATCH 7/8] Add Cartography spell allowing players to summon a 3d map of a given area with real time entity locations --- .../spells/instant/CartographySpell.java | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 core/src/main/java/com/nisovin/magicspells/spells/instant/CartographySpell.java diff --git a/core/src/main/java/com/nisovin/magicspells/spells/instant/CartographySpell.java b/core/src/main/java/com/nisovin/magicspells/spells/instant/CartographySpell.java new file mode 100644 index 000000000..4f3ce3870 --- /dev/null +++ b/core/src/main/java/com/nisovin/magicspells/spells/instant/CartographySpell.java @@ -0,0 +1,372 @@ +package com.nisovin.magicspells.spells.instant; + +import java.util.List; +import java.util.Set; +import java.util.HashSet; + +import com.nisovin.magicspells.util.MagicConfig; +import com.nisovin.magicspells.util.CastResult; +import com.nisovin.magicspells.spells.InstantSpell; +import com.nisovin.magicspells.util.SpellData; +import com.nisovin.magicspells.Spell.PostCastAction; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.BlockDisplay; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Mob; +import org.bukkit.util.Vector; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import com.nisovin.magicspells.util.config.ConfigData; + +public class CartographySpell extends InstantSpell { + + private final ConfigData areaSize; + private final ConfigData scale; + private final ConfigData period; + private final ConfigData iterations; + private final ConfigData durationTicks; + private final ConfigData relativeOffset; + private final ConfigData snapToBlockCenter; + private final ConfigData hideRoof; + + private final List allowedBlockStrings; + private final List deniedBlockStrings; + private final List roofMaterialStrings; + private Set allowedBlocks; + private Set deniedBlocks; + private Set roofMaterials; + + public CartographySpell(MagicConfig config, String spellName) { + super(config, spellName); + areaSize = getConfigDataInt("area-size", 10); + scale = getConfigDataDouble("scale", 0.2); + period = getConfigDataInt("period", 20); + iterations = getConfigDataInt("iterations", 5); + durationTicks = getConfigDataInt("duration-ticks", 100); + relativeOffset = getConfigDataVector("relative-offset", new Vector(0, 0, 0)); + snapToBlockCenter = getConfigDataBoolean("snap-to-block-center", true); + hideRoof = getConfigDataBoolean("hide-roof", true); + + allowedBlockStrings = getConfigStringList("allowed-blocks", null); + deniedBlockStrings = getConfigStringList("denied-blocks", null); + roofMaterialStrings = getConfigStringList("roof-materials", null); + } + + @Override + public void initialize() { + super.initialize(); + allowedBlocks = parseBlockDataSet(allowedBlockStrings); + deniedBlocks = parseBlockDataSet(deniedBlockStrings); + roofMaterials = parseBlockDataSet(roofMaterialStrings); + } + + @Override + public CastResult cast(SpellData data) { + if (!data.hasCaster()) return new CastResult(PostCastAction.ALREADY_HANDLED, data); + + LivingEntity caster = data.caster(); + + Location origin = caster.getLocation().add(caster.getLocation().getDirection().multiply(3)); + origin.setPitch(0f); + origin.setYaw(0f); + + int area = areaSize.get(data); + double scl = scale.get(data); + int periodTicks = period.get(data); + int maxIterations = iterations.get(data); + int visionDuration = durationTicks.get(data); + Vector relOffset = relativeOffset.get(data); + boolean snap = snapToBlockCenter.get(data); + boolean hideRoofVal = hideRoof.get(data); + + if (snap) { + origin.setX(origin.getBlockX() + 0.5); + origin.setZ(origin.getBlockZ() + 0.5); + } + + origin.setY(origin.getY() + (area * scl) / 2.0); + + Location targetLoc = caster.getTargetBlockExact(area) != null ? caster.getTargetBlockExact(area).getLocation() : caster.getLocation(); + + MiniMapVision vision = new MiniMapVision(targetLoc, origin, area, scl, visionDuration, periodTicks, maxIterations, relOffset, hideRoofVal, allowedBlocks, deniedBlocks, roofMaterials); + vision.start(); + + return new CastResult(PostCastAction.HANDLE_NORMALLY, data); + } + + private static class MiniMapVision implements Runnable { + + private final Location center; + private final Location displayOrigin; + private final int areaSize; + private final double scale; + private final int visionDuration; + private final int periodTicks; + private final int maxIterations; + private final Vector relOffset; + private final boolean hideRoof; + private final Set allowedBlocks; + private final Set deniedBlocks; + private final Set roofMaterials; + + private final java.util.Set visibleColumns = new java.util.HashSet<>(); + + private int iteration = 0; + private int taskId = -1; + private final java.util.List spawned = new java.util.ArrayList<>(); + + private MiniMapVision(Location center, Location displayOrigin, int areaSize, double scale, int visionDuration, int periodTicks, int maxIterations, Vector relOffset, boolean hideRoof, Set allowedBlocks, Set deniedBlocks, Set roofMaterials) { + this.center = center.clone(); + this.displayOrigin = displayOrigin.clone(); + this.areaSize = areaSize; + this.scale = scale; + this.visionDuration = visionDuration; + this.periodTicks = periodTicks; + this.maxIterations = maxIterations; + this.relOffset = relOffset; + this.hideRoof = hideRoof; + this.allowedBlocks = allowedBlocks; + this.deniedBlocks = deniedBlocks; + this.roofMaterials = roofMaterials; + } + + public void start() { + updateVision(); + if (maxIterations > 1) { + taskId = com.nisovin.magicspells.MagicSpells.scheduleRepeatingTask(this, periodTicks, periodTicks); + } + } + + @Override + public void run() { + iteration++; + clearVision(); + updateVision(); + if (iteration >= maxIterations - 1) { + com.nisovin.magicspells.MagicSpells.scheduleDelayedTask(this::clearVision, visionDuration); + com.nisovin.magicspells.MagicSpells.cancelTask(taskId); + } + } + + private void updateVision() { + World world = center.getWorld(); + int half = areaSize / 2; + + Location base = center.clone().add(relOffset); + int baseY = base.getBlockY(); + + visibleColumns.clear(); + + for (int x = -half; x < half; x++) { + for (int z = -half; z < half; z++) { + int worldX = base.getBlockX() + x; + int worldZ = base.getBlockZ() + z; + + Block visibleBlock = findVisibleBlock(world, worldX, worldZ, baseY); + if (visibleBlock == null) continue; + + // Render the top visible block for this column. + int relY = visibleBlock.getY() - baseY; + Vector offset = new Vector(x * scale, relY * scale, z * scale); + Location displayLoc = displayOrigin.clone().add(offset); + + BlockDisplay display = world.spawn(displayLoc, BlockDisplay.class, e -> { + e.setBlock(visibleBlock.getBlockData()); + e.setTransformation(new Transformation( + new Vector3f(0, 0, 0), + new Quaternionf(0, 0, 0, 1), + new Vector3f((float) scale, (float) scale, (float) scale), + new Quaternionf(0, 0, 0, 1) + )); + }); + spawned.add(display); + + // Also render the vertical stack below the visible block (e.g., full cactus + // or foliage column) down to the first denied block, air, or solid ground. + Block below = visibleBlock.getRelative(0, -1, 0); + while (true) { + if (below.getY() < world.getMinHeight()) break; + if (below.getType().isAir()) break; + + Block support = below.getRelative(0, -1, 0); + // Stop if the block under this one is denied by config. + if (isDenied(support)) break; + + if (!isAllowed(below)) { + below = support; + continue; + } + + int relYBelow = below.getY() - baseY; + Vector offsetBelow = new Vector(x * scale, relYBelow * scale, z * scale); + Location displayLocBelow = displayOrigin.clone().add(offsetBelow); + Block currentBelow = below; + + BlockDisplay displayBelow = world.spawn(displayLocBelow, BlockDisplay.class, e -> { + e.setBlock(currentBelow.getBlockData()); + e.setTransformation(new Transformation( + new Vector3f(0, 0, 0), + new Quaternionf(0, 0, 0, 1), + new Vector3f((float) scale, (float) scale, (float) scale), + new Quaternionf(0, 0, 0, 1) + )); + }); + spawned.add(displayBelow); + + // If this block is solid ground, stop stacking further down. + if (below.getType().isOccluding()) break; + + below = support; + } + + visibleColumns.add(encodeColumnKey(x, z)); + } + } + + spawnMiniMobs(world, base, half); + } + + private void clearVision() { + for (Entity e : spawned) e.remove(); + spawned.clear(); + } + + private boolean isDenied(Block block) { + if (deniedBlocks == null) return false; + + BlockData data = block.getBlockData(); + for (BlockData bd : deniedBlocks) { + if (data.matches(bd)) return true; + } + return false; + } + + private boolean isAllowed(Block block) { + if (allowedBlocks == null) return !block.getType().isAir(); + + BlockData data = block.getBlockData(); + for (BlockData bd : allowedBlocks) { + if (data.matches(bd)) return true; + } + return false; + } + + private boolean isRoof(Block block) { + if (roofMaterials == null) return false; + + BlockData data = block.getBlockData(); + for (BlockData bd : roofMaterials) { + if (data.matches(bd)) return true; + } + return false; + } + + private Block findVisibleBlock(World world, int x, int z, int baseY) { + Block block = world.getHighestBlockAt(x, z); + + // If the top-most block is denied, skip this column entirely. + if (isDenied(block)) return null; + + // Walk downward while the block is considered "see-through" or invalid. + while (true) { + if (block.getY() < world.getMinHeight()) return null; + + if (!block.getType().isAir() && !isRoof(block) && isAllowed(block) && hasOpenFace(block)) return block; + + Block below = block.getRelative(0, -1, 0); + // If we hit a denied block while searching, treat the column as denied. + if (isDenied(below)) return null; + block = below; + } + } + + private boolean hasOpenFace(Block block) { + // Check 6 neighboring faces; show only if at least one touches air. + int bx = block.getX(); + int by = block.getY(); + int bz = block.getZ(); + World world = block.getWorld(); + + return world.getBlockAt(bx + 1, by, bz).getType().isAir() + || world.getBlockAt(bx - 1, by, bz).getType().isAir() + || world.getBlockAt(bx, by + 1, bz).getType().isAir() + || world.getBlockAt(bx, by - 1, bz).getType().isAir() + || world.getBlockAt(bx, by, bz + 1).getType().isAir() + || world.getBlockAt(bx, by, bz - 1).getType().isAir(); + } + + private void spawnMiniMobs(World world, Location base, int half) { + for (Entity entity : world.getNearbyEntities(base, half, half, half)) { + if (!(entity instanceof Mob mob)) continue; + if (entity.getScoreboardTags().contains("MS_CARTO_MINI")) continue; + + Location mobLoc = mob.getLocation(); + double dx = mobLoc.getX() - base.getX(); + double dy = mobLoc.getY() - base.getY(); + double dz = mobLoc.getZ() - base.getZ(); + + // Check whether this mob's column is not denied according to the map. + int colX = mobLoc.getBlockX() - base.getBlockX(); + int colZ = mobLoc.getBlockZ() - base.getBlockZ(); + if (colX < -half || colX >= half || colZ < -half || colZ >= half) continue; + if (!visibleColumns.contains(encodeColumnKey(colX, colZ))) continue; + + Vector offset = new Vector(dx * scale, dy * scale, dz * scale); + Location displayLoc = displayOrigin.clone().add(offset); + + Entity mini = spawnMiniEntity(displayLoc, mob); + if (mini != null) spawned.add(mini); + } + } + + private Entity spawnMiniEntity(Location loc, Mob original) { + try { + Entity spawnedEntity = loc.getWorld().spawnEntity(loc, original.getType()); + if (spawnedEntity instanceof Mob mini) { + mini.setAI(false); + mini.setSilent(true); + mini.setInvulnerable(true); + mini.setRemoveWhenFarAway(true); + mini.addScoreboardTag("MS_CARTO_MINI"); + + // Match rotation to the original entity. + mini.setRotation(original.getLocation().getYaw(), original.getLocation().getPitch()); + + if (spawnedEntity instanceof LivingEntity living) { + try { + var attr = living.getAttribute(org.bukkit.attribute.Attribute.valueOf("SCALE")); + if (attr != null) attr.setBaseValue(scale); + } catch (Throwable ignored) {} + } + } + return spawnedEntity; + } catch (Throwable ignored) { + return null; + } + } + + private long encodeColumnKey(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + } + + 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 ca0c25907990a1ae5f43f83eaf3667b20a0efe92 Mon Sep 17 00:00:00 2001 From: DragonsAscent Date: Sun, 7 Jun 2026 00:45:01 -0400 Subject: [PATCH 8/8] Add ScoreCondition to ConditionManager --- .../com/nisovin/magicspells/util/managers/ConditionManager.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/com/nisovin/magicspells/util/managers/ConditionManager.java b/core/src/main/java/com/nisovin/magicspells/util/managers/ConditionManager.java index 2305a7b6f..2eecd6fbf 100644 --- a/core/src/main/java/com/nisovin/magicspells/util/managers/ConditionManager.java +++ b/core/src/main/java/com/nisovin/magicspells/util/managers/ConditionManager.java @@ -221,6 +221,7 @@ private void initialize() { addCondition(FixedTimeCondition.class); addCondition(UsingItemCondition.class); addCondition(InputCondition.class); + addCondition(ScoreCondition.class); } }