diff --git a/README.md b/README.md index afd40b1..dd6f204 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,29 @@ # DrawLib -A simple library to draw some simple shapes using particles in Paper servers +A simple library to draw some simple shapes using particles. ![Screenshot](./img/screenshot.png) +## Compatibility + +A single compiled jar auto-detects what the running server supports at +runtime and adapts accordingly -- there's nothing to configure. + +| Server software | Support | +|---|---| +| Paper / Paper forks (Purpur, Pufferfish, ...) | Full support: targeted, force-rendered particles | +| Vanilla Bukkit / CraftBukkit / Spigot | Supported: per-player targeted particles (no "force show past render distance", since that's a Paper-only flag) | +| Folia | Supported *if* you pass your `Plugin` instance to `new ShapeRenderer(plugin)` -- drawing is then automatically dispatched onto the correct region thread. Without a plugin instance, only safe if you already guarantee correct-thread calls yourself. | + +| Minecraft version | Support | +|---|---| +| 1.13 and newer | Full support, accurate arbitrary particle colour via `Particle.DustOptions` (handles the `REDSTONE` → `DUST` rename in 1.20.5 automatically) | +| 1.9 - 1.12 | Supported via a legacy colour approximation (`Particle.DustOptions` doesn't exist yet on these versions) | +| Below 1.9 | Not supported -- these versions predate Bukkit's `Particle` enum entirely | + +See `Compat.java` for the reflection-based detection this relies on. + ## Maven Add jitpack to your `repositories`: @@ -30,7 +49,9 @@ For other tools, see the [JitPack](https://jitpack.io/#funnyboy-roks/DrawLib) pa ## Usage ```java -ShapeRenderer renderer = new ShapeRenderer(); +// Pass your plugin instance so drawing works correctly on Folia too. +// (new ShapeRenderer() with no arguments still works everywhere except Folia.) +ShapeRenderer renderer = new ShapeRenderer(myPlugin); renderer.setColor(Color.RED); renderer.setStepSize(0.1); diff --git a/pom.xml b/pom.xml index cea53df..4f8bf86 100644 --- a/pom.xml +++ b/pom.xml @@ -6,13 +6,20 @@ com.funnyboyroks DrawLib - 1.1.0-SNAPSHOT + 1.2.0-SNAPSHOT jar DrawLib - 11 + + 8 UTF-8 @@ -60,6 +67,10 @@ + + spigot-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + papermc-repo https://repo.papermc.io/repository/maven-public/ @@ -71,10 +82,47 @@ + + + org.spigotmc + spigot-api + 1.13-R0.1-SNAPSHOT + provided + + + + + org.jetbrains + annotations + 24.1.0 + provided + - io.papermc.paper - paper-api - 1.19.2-R0.1-SNAPSHOT + com.google.code.findbugs + jsr305 + 3.0.2 provided diff --git a/src/main/java/com/funnyboyroks/drawlib/renderer/ShapeRenderer.java b/src/main/java/com/funnyboyroks/drawlib/renderer/ShapeRenderer.java index 87a7384..e1ee348 100644 --- a/src/main/java/com/funnyboyroks/drawlib/renderer/ShapeRenderer.java +++ b/src/main/java/com/funnyboyroks/drawlib/renderer/ShapeRenderer.java @@ -1,13 +1,16 @@ package com.funnyboyroks.drawlib.renderer; +import com.funnyboyroks.drawlib.util.Compat; import com.funnyboyroks.drawlib.util.Util; import com.funnyboyroks.drawlib.util.Vector; import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.Particle; +import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -18,8 +21,8 @@ *

* Intended Usage: *

- *     ShapeRenderer renderer = new ShapeRenderer();
- * 
+ *     ShapeRenderer renderer = new ShapeRenderer(myPlugin); // pass your plugin for Folia support
+ *
  *     renderer.setColor(Color.RED);
  *     renderer.setStepSize(0.1);
  *     renderer.setReceivers(player);
@@ -27,16 +30,62 @@
  * 
* * Note: The draw methods only show the particles once, it is recommended to draw them every tick. + * + *

Compatibility

+ * ShapeRenderer detects, at runtime, what the underlying server actually + * supports and adapts automatically -- a single compiled jar works across: + * + * Minecraft versions older than 1.9 aren't supported, since those predate + * Bukkit's {@code Particle} enum entirely (a fundamentally different, + * string/id-based effect API would be required). */ public class ShapeRenderer { + private final Plugin plugin; // Used only to schedule on the correct thread on Folia; may be null + private Color color; // Colour of the particles to be drawn private List receivers; // Players that will receive the particles - private boolean force; // Force show particles + private boolean force; // Force show particles (Paper only; ignored elsewhere) private double step_size = .1; // Step between two particles private boolean optimize; // Optimize particles + /** + * Create a renderer with no owning plugin. + *

+ * This is fine on every server platform except Folia -- if you + * might run on Folia, use {@link #ShapeRenderer(Plugin)} instead so + * particle calls are dispatched to the correct region thread. + */ public ShapeRenderer() { + this(null); + } + + /** + * @param plugin Your plugin instance. Required for correct behaviour on + * Folia (so drawing can be scheduled onto the right region + * thread); ignored/unused on every other server platform. + */ + public ShapeRenderer(@Nullable Plugin plugin) { + this.plugin = plugin; this.color = Color.RED; this.receivers = null; this.force = false; @@ -83,7 +132,9 @@ public void setReceivers(@NotNull Player... receivers) { } /** - * @param force If the particle should be forceshown to the players + * @param force If the particle should be forceshown to the players. Paper (and forks) only -- + * silently has no effect on vanilla Bukkit/Spigot, since that server software has + * no equivalent flag. */ public void setForceShow(boolean force) { this.force = force; @@ -95,21 +146,64 @@ public void setForceShow(boolean force) { * @param point The location to draw */ public void drawPoint(@NotNull Location point) { - point.getWorld().spawnParticle( - Particle.REDSTONE, - this.receivers, - null, - point.getX(), - point.getY(), - point.getZ(), - 1, - 0, - 0, - 0, - 0, - new Particle.DustOptions(this.color, .5f), - this.force - ); + Compat.runRegionAware(this.plugin, point, () -> this.spawnParticleAt(point)); + } + + /** + * Does the actual particle spawning, choosing the best API the running + * server supports. Always invoked on a thread that's safe to touch + * {@code point}'s world (either the calling thread on non-Folia servers, + * or the correct region thread on Folia). + */ + private void spawnParticleAt(@NotNull Location point) { + World world = point.getWorld(); + if (world == null || Compat.DUST_PARTICLE == null) { + return; + } + + if (Compat.HAS_DUST_OPTIONS) { + Particle.DustOptions data = new Particle.DustOptions(this.color, .5f); + + if (Compat.hasPaperParticleApi()) { + // Best case: Paper / Paper fork -- exact receivers + force-render support + Compat.spawnParticlePaper( + world, Compat.DUST_PARTICLE, this.receivers, + point.getX(), point.getY(), point.getZ(), data, this.force + ); + return; + } + + // Vanilla Bukkit/CraftBukkit/Spigot: no receivers/force overload, + // so target players manually (or broadcast if none were set). + if (this.receivers == null) { + world.spawnParticle(Compat.DUST_PARTICLE, point, 1, 0, 0, 0, 0, data); + } else { + for (Player p : this.receivers) { + p.spawnParticle(Compat.DUST_PARTICLE, point, 1, 0, 0, 0, 0, data); + } + } + return; + } + + // Pre-1.13 fallback: Particle.DustOptions doesn't exist yet. Fake + // the colour using the legacy "count = 0, offsets = RGB" redstone + // dust trick instead. + this.spawnLegacyColouredParticle(world, point); + } + + private void spawnLegacyColouredParticle(@NotNull World world, @NotNull Location point) { + // Red must be > 0 or the client treats the particle as invisible/default-coloured. + double r = Math.max(this.color.getRed() / 255d, 0.0001d); + double g = this.color.getGreen() / 255d; + double b = this.color.getBlue() / 255d; + + if (this.receivers == null) { + world.spawnParticle(Compat.DUST_PARTICLE, point, 0, r, g, b, 1); + } else { + for (Player p : this.receivers) { + p.spawnParticle(Compat.DUST_PARTICLE, point, 0, r, g, b, 1); + } + } } /** @@ -313,8 +407,3 @@ public void drawBlockFace(@NotNull Block block, @NotNull BlockFace face) { drawCuboid(pt1, pt2); } } - - - - - diff --git a/src/main/java/com/funnyboyroks/drawlib/util/Compat.java b/src/main/java/com/funnyboyroks/drawlib/util/Compat.java new file mode 100644 index 0000000..8d838a1 --- /dev/null +++ b/src/main/java/com/funnyboyroks/drawlib/util/Compat.java @@ -0,0 +1,194 @@ +package com.funnyboyroks.drawlib.util; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Runtime feature-detection so that a single compiled DrawLib jar can run + * as-is across the widest practical range of server software and Minecraft + * versions, degrading gracefully instead of hard-failing when an API isn't + * present: + *

+ *

+ * Every check below is resolved once via reflection and cached in a static + * field. None of these lookups throw if the class/method in question doesn't + * exist on the running server -- they simply report "not available" so the + * caller can fall back to an older/simpler code path. + */ +public final class Compat { + + private static final Logger LOG = Logger.getLogger("DrawLib"); + + private Compat() { + } + + // --------------------------------------------------------------- + // Folia detection + // --------------------------------------------------------------- + + /** True if this server is running Folia (regionised multithreading). */ + public static final boolean IS_FOLIA = classExists("io.papermc.paper.threadedregions.RegionizedServer"); + + private static final Method GET_REGION_SCHEDULER = IS_FOLIA + ? findMethod(Bukkit.class, "getRegionScheduler") + : null; + + private static final Method REGION_SCHEDULER_EXECUTE = IS_FOLIA + ? findRegionSchedulerExecute() + : null; + + // --------------------------------------------------------------- + // Paper-only expanded spawnParticle(receivers, source, ..., force) + // --------------------------------------------------------------- + + private static final Method PAPER_SPAWN_PARTICLE = findPaperSpawnParticle(); + + /** True if the Paper-only targeted/force-render spawnParticle overload is available. */ + public static boolean hasPaperParticleApi() { + return PAPER_SPAWN_PARTICLE != null; + } + + // --------------------------------------------------------------- + // Particle.DustOptions (added 1.13) + // --------------------------------------------------------------- + + /** True if {@code Particle.DustOptions} exists on this server (MC 1.13+). */ + public static final boolean HAS_DUST_OPTIONS = classExists("org.bukkit.Particle$DustOptions"); + + // --------------------------------------------------------------- + // Particle enum constant: DUST (1.20.5+) or REDSTONE (older) + // --------------------------------------------------------------- + + /** + * The correct enum constant for a colourable dust particle on this + * server, or {@code null} if neither name resolves (should not happen + * on any real Bukkit-family server that has a Particle enum at all). + */ + public static final Particle DUST_PARTICLE = resolveDustParticle(); + + // --------------------------------------------------------------- + // Detection helpers + // --------------------------------------------------------------- + + private static boolean classExists(String name) { + try { + Class.forName(name); + return true; + } catch (Throwable t) { + return false; + } + } + + private static Method findMethod(Class clazz, String name, Class... params) { + try { + return clazz.getMethod(name, params); + } catch (Throwable t) { + return null; + } + } + + private static Method findPaperSpawnParticle() { + try { + return org.bukkit.World.class.getMethod( + "spawnParticle", + Particle.class, List.class, Player.class, + double.class, double.class, double.class, + int.class, double.class, double.class, double.class, + double.class, Object.class, boolean.class + ); + } catch (Throwable t) { + return null; + } + } + + private static Method findRegionSchedulerExecute() { + try { + Class schedulerClass = Class.forName("io.papermc.paper.threadedregions.scheduler.RegionScheduler"); + for (Method m : schedulerClass.getMethods()) { + // execute(Plugin, Location, Runnable) + if (m.getName().equals("execute") && m.getParameterCount() == 3) { + return m; + } + } + } catch (Throwable ignored) { + // Not on Folia, or API shape changed -- caller falls back to running inline. + } + return null; + } + + private static Particle resolveDustParticle() { + try { + return Particle.valueOf("DUST"); + } catch (Throwable t) { + try { + return Particle.valueOf("REDSTONE"); + } catch (Throwable t2) { + return null; + } + } + } + + // --------------------------------------------------------------- + // Invocation helpers + // --------------------------------------------------------------- + + /** + * Calls Paper's expanded {@code spawnParticle(receivers, source, ..., force)} + * overload via reflection. Only call this after checking {@link #hasPaperParticleApi()}. + */ + public static void spawnParticlePaper( + org.bukkit.World world, Particle particle, List receivers, + double x, double y, double z, T data, boolean force + ) { + try { + PAPER_SPAWN_PARTICLE.invoke( + world, particle, receivers, null, + x, y, z, 1, 0d, 0d, 0d, 0d, data, force + ); + } catch (IllegalAccessException | InvocationTargetException e) { + LOG.log(Level.WARNING, "DrawLib: failed to call Paper's spawnParticle via reflection", e); + } + } + + /** + * Runs {@code task} on the Folia region thread that owns {@code location}. + * On non-Folia servers (or if no plugin instance was supplied), the task + * just runs immediately on the calling thread, which is correct/required + * behaviour for every other server platform. + * + * @param plugin The owning plugin, used to schedule on Folia. May be {@code null} + * on non-Folia servers; must be non-null on Folia or the task is + * simply run inline (which may violate Folia's thread-ownership + * rules if called from an unrelated thread). + * @param location The location the task operates on. + * @param task The work to run. + */ + public static void runRegionAware(Plugin plugin, Location location, Runnable task) { + if (!IS_FOLIA || plugin == null || GET_REGION_SCHEDULER == null || REGION_SCHEDULER_EXECUTE == null) { + task.run(); + return; + } + try { + Object scheduler = GET_REGION_SCHEDULER.invoke(null); + REGION_SCHEDULER_EXECUTE.invoke(scheduler, plugin, location, task); + } catch (Throwable t) { + LOG.log(Level.WARNING, "DrawLib: failed to schedule on Folia region thread, running inline", t); + task.run(); + } + } +}