From 1b693c76b8fd4efef07ed79a5f865a86119f0efc Mon Sep 17 00:00:00 2001 From: twme-ai <299940106+twme-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:30:44 +0000 Subject: [PATCH] feat: add animated updates, styles, and box groups Shapes can now change after spawning without respawning entities: - setPoints on LineShape, PolylineShape, TriangleShape, and ParallelogramShape updates the existing Text Displays. - setColor, setInterpolationDuration, and setTeleportDuration expose Display interpolation; updates restart the client interpolation, so geometry and background color animate. - translate moves the rendered geometry, riding the root anchor when enabled, unlike teleportOrigin which only rebases. - Packet updates of one shape are sent in a single VirtualEntities bundle, and PacketShapeFactory#batch groups updates across shapes. Adds ShapeStyle, ShapeGroup, BoxOutline, and BoxFaces, plus the platform-neutral DisplayTransform, ShapeGeometry, and BoxGeometry. Packet, Paper, and Spigot shapes now share one base class per platform instead of duplicating lifecycle code, and every public builder method is preserved. The new Shape methods are default methods, so existing third-party implementations still compile. Fixes getSegmentCount reporting two segments for a closed two-point polyline, and pins the E2E fixture to the checkout's version instead of the stale 3.0.0 artifact. The E2E now also verifies an animated update and an animated root-anchor move on a real Paper server. Co-Authored-By: Claude Opus 5.5 --- README.md | 53 ++- .../geometry/BoxGeometry.java | 76 ++++ .../geometry/DisplayTransform.java | 119 +++++ .../geometry/ShapeGeometry.java | 73 +++ .../twme/textdisplayshape/shape/BoxFaces.java | 57 +++ .../textdisplayshape/shape/BoxOutline.java | 55 +++ .../textdisplayshape/shape/LineShape.java | 17 + .../shape/ParallelogramShape.java | 19 + .../textdisplayshape/shape/PolylineShape.java | 17 + .../twme/textdisplayshape/shape/Shape.java | 86 ++++ .../textdisplayshape/shape/ShapeBuilder.java | 38 ++ .../textdisplayshape/shape/ShapeGroup.java | 187 ++++++++ .../textdisplayshape/shape/ShapeStyle.java | 86 ++++ .../textdisplayshape/shape/TriangleShape.java | 16 + .../geometry/ShapeGeometryTest.java | 91 ++++ .../shape/ShapeGroupTest.java | 133 ++++++ integration/mineflayer/run-e2e.sh | 4 +- integration/mineflayer/test.mjs | 46 ++ integration/paper-plugin/pom.xml | 4 +- .../TextDisplayShapesIntegrationPlugin.java | 22 + packet/pom.xml | 6 + .../packet/AbstractPacketShape.java | 427 ++++++++++++++++++ .../textdisplayshape/packet/PacketLine.java | 228 +--------- .../packet/PacketParallelogram.java | 236 ++-------- .../packet/PacketPolyline.java | 260 ++--------- .../packet/PacketShapeFactory.java | 38 ++ .../packet/PacketTriangle.java | 240 ++-------- .../packet/VirtualTextDisplaySupport.java | 62 ++- .../packet/PacketShapeAnimationTest.java | 217 +++++++++ .../bukkit/AbstractBukkitShape.java | 374 +++++++++++++++ .../textdisplayshape/bukkit/BukkitLine.java | 230 +--------- .../bukkit/BukkitParallelogram.java | 147 ++---- .../bukkit/BukkitPolyline.java | 176 +++----- .../bukkit/BukkitShapeFactory.java | 41 ++ .../bukkit/BukkitTriangle.java | 222 ++------- .../bukkit/AbstractBukkitShape.java | 372 +++++++++++++++ .../textdisplayshape/bukkit/BukkitLine.java | 152 ++----- .../bukkit/BukkitParallelogram.java | 142 ++---- .../bukkit/BukkitPolyline.java | 172 +++---- .../bukkit/BukkitShapeFactory.java | 41 ++ .../bukkit/BukkitTriangle.java | 142 ++---- 41 files changed, 3226 insertions(+), 1898 deletions(-) create mode 100644 api/src/main/java/dev/twme/textdisplayshape/geometry/BoxGeometry.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/geometry/DisplayTransform.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/geometry/ShapeGeometry.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/BoxFaces.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/BoxOutline.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/LineShape.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/ParallelogramShape.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/PolylineShape.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/ShapeGroup.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/ShapeStyle.java create mode 100644 api/src/main/java/dev/twme/textdisplayshape/shape/TriangleShape.java create mode 100644 api/src/test/java/dev/twme/textdisplayshape/geometry/ShapeGeometryTest.java create mode 100644 api/src/test/java/dev/twme/textdisplayshape/shape/ShapeGroupTest.java create mode 100644 packet/src/main/java/dev/twme/textdisplayshape/packet/AbstractPacketShape.java create mode 100644 packet/src/test/java/dev/twme/textdisplayshape/packet/PacketShapeAnimationTest.java create mode 100644 paper/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java create mode 100644 spigot/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java diff --git a/README.md b/README.md index 3ebdf9c..32a3756 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,50 @@ The packet module never creates server-side entities. It resolves viewer UUIDs t `teleportOrigin(x, y, z)` preserves a shape's world-space geometry by shifting Text Display translation metadata while relocating its virtual origin. For root-anchored shapes, the metadata updates and root teleport are sent in one VirtualEntities bundle on bundle-capable clients, avoiding an intermediate visual jump. +## Animation and updates + +Spawned shapes can change after they appear. Updates reuse the existing Text Display entities, so the client animates them instead of respawning anything: + +```java +PacketLine line = shapes.line(origin, start, end, 0.05f) + .interpolationDuration(4) // geometry and color changes animate over 4 ticks + .teleportDuration(2) // translate() animates over 2 ticks + .build(); +line.addViewer(player.getUniqueId()); +line.spawn(); + +line.setPoints(newStart, newEnd); // stretch or rotate the line in place +line.setColor(0x8000FF00); // fade to translucent green +line.translate(0, 1, 0); // move the whole shape one block up +``` + +- `setPoints(...)` is available on `LineShape`, `PolylineShape`, `TriangleShape`, and `ParallelogramShape`. In packet mode, every part of a shape is updated in one VirtualEntities bundle, so it changes in a single frame. +- A polyline whose point count grows animates the existing segments; new segments appear immediately and removed ones disappear. +- Text Display background color follows the same interpolation timing as the transformation. +- `translate` moves the geometry, unlike `teleportOrigin`, which only rebases the entities. Root-anchored packet shapes move by teleporting only their anchor. +- Invalid geometry, such as a zero-length line, is rejected before anything is sent or stored. + +### Styles, groups, and boxes + +`ShapeStyle` bundles appearance and animation settings and applies them to any builder with `style(...)`. `ShapeGroup` manages several shapes as one: lifecycle, viewers, color, animation settings, and movement. `BoxOutline` (12 edges) and `BoxFaces` (6 outward-facing faces) are groups whose `setBounds` moves every part in place, which makes animated selection boxes and cursors cheap: + +```java +ShapeStyle style = ShapeStyle.DEFAULT.withColor(0xC0FFFFFF).withInterpolationDuration(2); +BoxOutline cursor = shapes.boxOutline(origin, min, max, 0.02f, style); +cursor.addViewer(player.getUniqueId()); +cursor.spawn(); + +cursor.setBounds(newMin, newMax); // glides to the next cell +shapes.batch(() -> { // packet mode: several shapes in one frame + cursor.setColor(0xC0FF4040); + preview.setPoints(a, b, c); +}); +``` + +Direct Paper and Spigot shapes support the same updates through the Bukkit Display API. Their updates must run on the thread that owns the entities. + +`ShapeGeometry` and `BoxGeometry` expose the platform-neutral transforms and box edges and faces for custom renderers. + ## Shape API All shape implementations support: @@ -133,8 +177,13 @@ All shape implementations support: | `getViewerUUIDs()` | Return a copy of configured viewers. | | `getEntityUUIDs()` | Return Text Display UUIDs for the shape. | | `teleportOrigin(x, y, z)` | Rebase the virtual origin without moving the rendered geometry. | +| `translate(dx, dy, dz)` | Move the rendered geometry, animated by the teleport duration. | +| `setColor(argb)` / `getColor()` | Change the background color, animated by the interpolation duration. | +| `setInterpolationDuration(ticks)` | Animate later geometry and color updates. | +| `setTeleportDuration(ticks)` | Animate later `translate` calls (0-59 ticks). | +| `getEntityCount()` | Count the Text Display entities in use. | -Builders provide color, brightness, see-through, view range, double-sided, root-anchor, and line roll controls. The supported shape types are Line, Polyline, Triangle, and Parallelogram. +Builders provide color, brightness, see-through, view range, double-sided, root-anchor, interpolation duration, teleport duration, style, and line roll controls. The supported shape types are Line, Polyline, Triangle, and Parallelogram, plus the `BoxOutline` and `BoxFaces` groups. ## Migrating From 2.x @@ -145,7 +194,7 @@ Builders provide color, brightness, see-through, view range, double-sided, root- ## Verification -`integration/mineflayer/run-e2e.sh` launches Paper 1.21.11 with PacketEvents, creates a packet-only root-anchored line, and verifies from Mineflayer that Text Display spawn, metadata rebase, root movement, and packet bundle ordering all work. The same test can run from the manual GitHub Actions E2E workflow. Mineflayer still only speaks up to Minecraft 26.1, so that harness keeps its 1.21.11 server even though the modules target 26.3. +`integration/mineflayer/run-e2e.sh` launches Paper 1.21.11 with PacketEvents, creates a packet-only root-anchored line, and verifies from Mineflayer that Text Display spawn, metadata rebase, root movement, and packet bundle ordering all work. It also checks that an animated `setPoints` and `setColor` update reuses the same entity with the requested interpolation, and that `translate` moves the root anchor with the requested teleport duration. The fixture is compiled against the version installed from the current checkout. The same test can run from the manual GitHub Actions E2E workflow. Mineflayer still only speaks up to Minecraft 26.1, so that harness keeps its 1.21.11 server even though the modules target 26.3. ## Credits diff --git a/api/src/main/java/dev/twme/textdisplayshape/geometry/BoxGeometry.java b/api/src/main/java/dev/twme/textdisplayshape/geometry/BoxGeometry.java new file mode 100644 index 0000000..a5eb268 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/geometry/BoxGeometry.java @@ -0,0 +1,76 @@ +package dev.twme.textdisplayshape.geometry; + +import java.util.ArrayList; +import java.util.List; + +import org.joml.Vector3f; + +/** Edges and faces of an axis-aligned box. */ +public final class BoxGeometry { + + /** One of the twelve box edges. */ + public record Edge(Vector3f from, Vector3f to) { + public Edge { + from = new Vector3f(from); + to = new Vector3f(to); + } + } + + /** + * One of the six box faces as a parallelogram: {@code corner} is shared by + * the two edges ending at {@code first} and {@code second}. The front side + * ({@code (first - corner) x (second - corner)}) faces outward. + */ + public record Face(Vector3f corner, Vector3f first, Vector3f second) { + public Face { + corner = new Vector3f(corner); + first = new Vector3f(first); + second = new Vector3f(second); + } + } + + private BoxGeometry() { + } + + /** The eight corners, indexed by bits x=4, y=2, z=1. */ + public static List corners(Vector3f min, Vector3f max) { + Vector3f low = new Vector3f(min).min(max); + Vector3f high = new Vector3f(min).max(max); + List corners = new ArrayList<>(8); + for (int index = 0; index < 8; index++) { + corners.add(new Vector3f( + (index & 4) != 0 ? high.x : low.x, + (index & 2) != 0 ? high.y : low.y, + (index & 1) != 0 ? high.z : low.z)); + } + return corners; + } + + /** The twelve edges in a stable order: four along X, four along Y, four along Z. */ + public static List edges(Vector3f min, Vector3f max) { + List c = corners(min, max); + List edges = new ArrayList<>(12); + int[][] pairs = { + {0, 4}, {1, 5}, {2, 6}, {3, 7}, + {0, 2}, {1, 3}, {4, 6}, {5, 7}, + {0, 1}, {2, 3}, {4, 5}, {6, 7} + }; + for (int[] pair : pairs) { + edges.add(new Edge(c.get(pair[0]), c.get(pair[1]))); + } + return edges; + } + + /** The six faces with outward front sides: -X, +X, -Y, +Y, -Z, +Z. */ + public static List faces(Vector3f min, Vector3f max) { + List c = corners(min, max); + List faces = new ArrayList<>(6); + faces.add(new Face(c.get(0), c.get(1), c.get(2))); + faces.add(new Face(c.get(4), c.get(6), c.get(5))); + faces.add(new Face(c.get(0), c.get(4), c.get(1))); + faces.add(new Face(c.get(2), c.get(3), c.get(6))); + faces.add(new Face(c.get(0), c.get(2), c.get(4))); + faces.add(new Face(c.get(1), c.get(5), c.get(3))); + return faces; + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/geometry/DisplayTransform.java b/api/src/main/java/dev/twme/textdisplayshape/geometry/DisplayTransform.java new file mode 100644 index 0000000..3140bdf --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/geometry/DisplayTransform.java @@ -0,0 +1,119 @@ +package dev.twme.textdisplayshape.geometry; + +import java.util.Objects; + +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import dev.twme.textdisplayshape.util.TRSResult; + +/** + * One Text Display transformation in Minecraft's + * {@code translation * leftRotation * scale * rightRotation} form. + * + *

Translations produced by {@link ShapeGeometry} are absolute world + * coordinates; renderers subtract the entity origin before sending them. + * Instances are immutable: every accessor returns a defensive copy.

+ */ +public final class DisplayTransform { + + private final Vector3f translation; + private final Quaternionf leftRotation; + private final Vector3f scale; + private final Quaternionf rightRotation; + + public DisplayTransform( + Vector3f translation, + Quaternionf leftRotation, + Vector3f scale, + Quaternionf rightRotation + ) { + this.translation = new Vector3f(Objects.requireNonNull(translation, "translation")); + this.leftRotation = new Quaternionf(Objects.requireNonNull(leftRotation, "leftRotation")); + this.scale = new Vector3f(Objects.requireNonNull(scale, "scale")); + this.rightRotation = new Quaternionf(Objects.requireNonNull(rightRotation, "rightRotation")); + } + + /** Converts an analytic TRS decomposition. */ + public static DisplayTransform of(TRSResult result) { + return new DisplayTransform( + result.translation(), + result.leftRotation(), + result.scale(), + result.rightRotation() + ); + } + + /** + * Converts a matrix without shear, such as a line matrix. The rotation is + * read without normalizing out scale, matching how lines have always been + * sent in packet mode. + */ + public static DisplayTransform ofUnshearedMatrix(Matrix4f matrix) { + Vector3f translation = matrix.getTranslation(new Vector3f()); + Vector3f scale = matrix.getScale(new Vector3f()); + Quaternionf rotation = matrix.getUnnormalizedRotation(new Quaternionf()); + return new DisplayTransform(translation, rotation, scale, new Quaternionf()); + } + + public Vector3f translation() { + return new Vector3f(translation); + } + + public Quaternionf leftRotation() { + return new Quaternionf(leftRotation); + } + + public Vector3f scale() { + return new Vector3f(scale); + } + + public Quaternionf rightRotation() { + return new Quaternionf(rightRotation); + } + + /** Returns a copy whose translation is expressed relative to {@code (x, y, z)}. */ + public DisplayTransform relativeTo(double x, double y, double z) { + return new DisplayTransform( + new Vector3f(translation).sub((float) x, (float) y, (float) z), + leftRotation, + scale, + rightRotation + ); + } + + /** Returns a copy with every scale component multiplied by {@code factor}. */ + public DisplayTransform scaled(float factor) { + return new DisplayTransform(translation, leftRotation, new Vector3f(scale).mul(factor), rightRotation); + } + + /** Converts back to a matrix, for platforms that accept one. */ + public Matrix4f toMatrix() { + return new Matrix4f() + .translate(translation) + .rotate(leftRotation) + .scale(scale) + .rotate(rightRotation); + } + + @Override + public boolean equals(Object other) { + return other instanceof DisplayTransform that + && translation.equals(that.translation) + && leftRotation.equals(that.leftRotation) + && scale.equals(that.scale) + && rightRotation.equals(that.rightRotation); + } + + @Override + public int hashCode() { + return Objects.hash(translation, leftRotation, scale, rightRotation); + } + + @Override + public String toString() { + return "DisplayTransform[translation=" + translation + ", leftRotation=" + leftRotation + + ", scale=" + scale + ", rightRotation=" + rightRotation + "]"; + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/geometry/ShapeGeometry.java b/api/src/main/java/dev/twme/textdisplayshape/geometry/ShapeGeometry.java new file mode 100644 index 0000000..e289cc8 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/geometry/ShapeGeometry.java @@ -0,0 +1,73 @@ +package dev.twme.textdisplayshape.geometry; + +import java.util.ArrayList; +import java.util.List; + +import org.joml.Vector3f; + +import dev.twme.textdisplayshape.util.TRSResult; +import dev.twme.textdisplayshape.util.TextDisplayUtil; + +/** + * Platform-neutral Text Display transforms for every built-in shape. All + * renderers use these methods, so direct and packet shapes stay identical. + */ +public final class ShapeGeometry { + + private ShapeGeometry() { + } + + /** One entity per face: the front, then the back when double-sided. */ + public static List line( + Vector3f p1, Vector3f p2, float thickness, float roll, boolean doubleSided) { + List transforms = new ArrayList<>(doubleSided ? 2 : 1); + transforms.add(DisplayTransform.ofUnshearedMatrix(TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll))); + if (doubleSided) { + transforms.add(DisplayTransform.ofUnshearedMatrix( + TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll))); + } + return transforms; + } + + /** Segments in order, then the closing segment when {@code closed}. */ + public static List polyline( + List points, float thickness, float roll, boolean closed, boolean doubleSided) { + List transforms = new ArrayList<>(); + if (points.size() < 2) { + return transforms; + } + for (int index = 0; index < points.size() - 1; index++) { + transforms.addAll(line(points.get(index), points.get(index + 1), thickness, roll, doubleSided)); + } + if (closed && points.size() > 2) { + transforms.addAll(line(points.get(points.size() - 1), points.get(0), thickness, roll, doubleSided)); + } + return transforms; + } + + /** Three entities per face. */ + public static List triangle( + Vector3f p1, Vector3f p2, Vector3f p3, boolean doubleSided) { + List transforms = new ArrayList<>(doubleSided ? 6 : 3); + for (TRSResult result : TextDisplayUtil.computeTriangleTRS(p1, p2, p3)) { + transforms.add(DisplayTransform.of(result)); + } + if (doubleSided) { + for (TRSResult result : TextDisplayUtil.computeTriangleTRS(p1, p3, p2)) { + transforms.add(DisplayTransform.of(result)); + } + } + return transforms; + } + + /** One entity per face. */ + public static List parallelogram( + Vector3f p1, Vector3f p2, Vector3f p3, boolean doubleSided) { + List transforms = new ArrayList<>(doubleSided ? 2 : 1); + transforms.add(DisplayTransform.of(TextDisplayUtil.computeParallelogramTRS(p1, p2, p3))); + if (doubleSided) { + transforms.add(DisplayTransform.of(TextDisplayUtil.computeParallelogramTRS(p1, p3, p2))); + } + return transforms; + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/BoxFaces.java b/api/src/main/java/dev/twme/textdisplayshape/shape/BoxFaces.java new file mode 100644 index 0000000..2aafb5d --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/BoxFaces.java @@ -0,0 +1,57 @@ +package dev.twme.textdisplayshape.shape; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import org.joml.Vector3f; + +import dev.twme.textdisplayshape.geometry.BoxGeometry; + +/** + * The six faces of an axis-aligned box, each facing outward. + * {@link #setBounds} moves every face in place. + */ +public class BoxFaces extends ShapeGroup { + + private final List faces = new ArrayList<>(6); + private Vector3f min; + private Vector3f max; + + /** + * @param min one corner of the box + * @param max the opposite corner + * @param faceFactory creates an unspawned parallelogram for each face + */ + public BoxFaces(Vector3f min, Vector3f max, + Function faceFactory) { + Objects.requireNonNull(faceFactory, "faceFactory"); + this.min = new Vector3f(min); + this.max = new Vector3f(max); + for (BoxGeometry.Face face : BoxGeometry.faces(min, max)) { + ParallelogramShape shape = Objects.requireNonNull(faceFactory.apply(face), "faceFactory result"); + faces.add(shape); + add(shape); + } + } + + public Vector3f getMin() { + return new Vector3f(min); + } + + public Vector3f getMax() { + return new Vector3f(max); + } + + /** Moves every face to the new box. */ + public void setBounds(Vector3f newMin, Vector3f newMax) { + this.min = new Vector3f(newMin); + this.max = new Vector3f(newMax); + List geometry = BoxGeometry.faces(newMin, newMax); + for (int index = 0; index < faces.size(); index++) { + BoxGeometry.Face face = geometry.get(index); + faces.get(index).setPoints(face.corner(), face.first(), face.second()); + } + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/BoxOutline.java b/api/src/main/java/dev/twme/textdisplayshape/shape/BoxOutline.java new file mode 100644 index 0000000..e533016 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/BoxOutline.java @@ -0,0 +1,55 @@ +package dev.twme.textdisplayshape.shape; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import org.joml.Vector3f; + +import dev.twme.textdisplayshape.geometry.BoxGeometry; + +/** + * The twelve edges of an axis-aligned box. {@link #setBounds} moves every + * edge in place, so a spawned outline animates when interpolation is set. + */ +public class BoxOutline extends ShapeGroup { + + private final List edges = new ArrayList<>(12); + private Vector3f min; + private Vector3f max; + + /** + * @param min one corner of the box + * @param max the opposite corner + * @param lineFactory creates an unspawned line for each edge + */ + public BoxOutline(Vector3f min, Vector3f max, Function lineFactory) { + Objects.requireNonNull(lineFactory, "lineFactory"); + this.min = new Vector3f(min); + this.max = new Vector3f(max); + for (BoxGeometry.Edge edge : BoxGeometry.edges(min, max)) { + LineShape line = Objects.requireNonNull(lineFactory.apply(edge), "lineFactory result"); + edges.add(line); + add(line); + } + } + + public Vector3f getMin() { + return new Vector3f(min); + } + + public Vector3f getMax() { + return new Vector3f(max); + } + + /** Moves every edge to the new box. */ + public void setBounds(Vector3f newMin, Vector3f newMax) { + this.min = new Vector3f(newMin); + this.max = new Vector3f(newMax); + List geometry = BoxGeometry.edges(newMin, newMax); + for (int index = 0; index < edges.size(); index++) { + edges.get(index).setPoints(geometry.get(index).from(), geometry.get(index).to()); + } + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/LineShape.java b/api/src/main/java/dev/twme/textdisplayshape/shape/LineShape.java new file mode 100644 index 0000000..4b7a18a --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/LineShape.java @@ -0,0 +1,17 @@ +package dev.twme.textdisplayshape.shape; + +import org.joml.Vector3f; + +/** A line whose endpoints can move after it has spawned. */ +public interface LineShape extends Shape { + + Vector3f getStart(); + + Vector3f getEnd(); + + /** + * Moves both endpoints. A spawned line updates its existing entities, so + * the change is animated over {@link #getInterpolationDuration()} ticks. + */ + void setPoints(Vector3f start, Vector3f end); +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/ParallelogramShape.java b/api/src/main/java/dev/twme/textdisplayshape/shape/ParallelogramShape.java new file mode 100644 index 0000000..affb507 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/ParallelogramShape.java @@ -0,0 +1,19 @@ +package dev.twme.textdisplayshape.shape; + +import org.joml.Vector3f; + +/** + * A parallelogram whose defining corner and edges can move after it has + * spawned. The fourth corner is {@code point2 + point3 - point1}. + */ +public interface ParallelogramShape extends Shape { + + Vector3f getPoint1(); + + Vector3f getPoint2(); + + Vector3f getPoint3(); + + /** Moves the defining points, animated when interpolation is enabled. */ + void setPoints(Vector3f point1, Vector3f point2, Vector3f point3); +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/PolylineShape.java b/api/src/main/java/dev/twme/textdisplayshape/shape/PolylineShape.java new file mode 100644 index 0000000..fdf4f9d --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/PolylineShape.java @@ -0,0 +1,17 @@ +package dev.twme.textdisplayshape.shape; + +import java.util.List; + +import org.joml.Vector3f; + +/** Connected line segments whose points can change after spawning. */ +public interface PolylineShape extends Shape { + + List getPoints(); + + /** + * Replaces every point. Segments that still exist are animated; segments + * added by a longer list appear immediately and surplus ones are removed. + */ + void setPoints(List points); +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/Shape.java b/api/src/main/java/dev/twme/textdisplayshape/shape/Shape.java index 38f6bca..25e153e 100644 --- a/api/src/main/java/dev/twme/textdisplayshape/shape/Shape.java +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/Shape.java @@ -13,6 +13,9 @@ */ public interface Shape { + /** Largest teleport duration the client accepts, in ticks. */ + int MAX_TELEPORT_DURATION = 59; + /** * Spawns the shape into the world. */ @@ -79,4 +82,87 @@ public interface Shape { * @param z the new origin Z coordinate */ void teleportOrigin(double x, double y, double z); + + /** + * Returns the number of Text Display entities currently used by this shape. + * + * @return entity count, zero when not spawned + */ + default int getEntityCount() { + return getEntityUUIDs().size(); + } + + /** + * Changes the background color. When interpolation is enabled the client + * fades between colors over {@link #getInterpolationDuration()} ticks. + * + * @param argb the color in ARGB format + * @throws UnsupportedOperationException if the implementation cannot recolor + */ + default void setColor(int argb) { + throw new UnsupportedOperationException(getClass().getName() + " does not support setColor"); + } + + /** + * Returns the current background color in ARGB format. + * + * @return the color + * @throws UnsupportedOperationException if the implementation does not track color + */ + default int getColor() { + throw new UnsupportedOperationException(getClass().getName() + " does not support getColor"); + } + + /** + * Sets how many ticks later geometry and color updates take to animate. + * Zero applies changes immediately. Spawning is never animated. + * + * @param ticks non-negative duration in ticks + */ + default void setInterpolationDuration(int ticks) { + throw new UnsupportedOperationException( + getClass().getName() + " does not support interpolation"); + } + + /** + * Returns the interpolation duration used for updates. + * + * @return duration in ticks, zero when updates are immediate + */ + default int getInterpolationDuration() { + return 0; + } + + /** + * Sets how many ticks {@link #translate} movements take to animate. + * + * @param ticks duration between 0 and {@link #MAX_TELEPORT_DURATION} + */ + default void setTeleportDuration(int ticks) { + throw new UnsupportedOperationException( + getClass().getName() + " does not support teleport interpolation"); + } + + /** + * Returns the duration used by {@link #translate}. + * + * @return duration in ticks, zero when movement is immediate + */ + default int getTeleportDuration() { + return 0; + } + + /** + * Moves the whole shape by an offset in world space. Unlike + * {@link #teleportOrigin}, the rendered geometry moves too; when a + * teleport duration is set the client animates the movement. + * + * @param dx X offset + * @param dy Y offset + * @param dz Z offset + * @throws UnsupportedOperationException if the implementation cannot move + */ + default void translate(double dx, double dy, double dz) { + throw new UnsupportedOperationException(getClass().getName() + " does not support translate"); + } } diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeBuilder.java b/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeBuilder.java index d494645..a496d46 100644 --- a/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeBuilder.java +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeBuilder.java @@ -68,6 +68,44 @@ default ShapeBuilder rootAnchor(boolean enabled) { return this; } + /** + * Sets how many ticks geometry and color updates take to animate. + * + * @param ticks non-negative duration in ticks + * @return this builder + */ + default ShapeBuilder interpolationDuration(int ticks) { + return this; + } + + /** + * Sets how many ticks {@link Shape#translate} movements take to animate. + * + * @param ticks duration between 0 and {@link Shape#MAX_TELEPORT_DURATION} + * @return this builder + */ + default ShapeBuilder teleportDuration(int ticks) { + return this; + } + + /** + * Applies every setting of a style. + * + * @param style the style to apply + * @return this builder + */ + default ShapeBuilder style(ShapeStyle style) { + color(style.argbColor()); + doubleSided(style.doubleSided()); + brightness(style.blockLight(), style.skyLight()); + seeThrough(style.seeThrough()); + viewRange(style.viewRange()); + rootAnchor(style.rootAnchor()); + interpolationDuration(style.interpolationDuration()); + teleportDuration(style.teleportDuration()); + return this; + } + /** * Builds the shape instance. * diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeGroup.java b/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeGroup.java new file mode 100644 index 0000000..6060b08 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeGroup.java @@ -0,0 +1,187 @@ +package dev.twme.textdisplayshape.shape; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** + * Several shapes managed as one: lifecycle, viewers, color, animation + * settings, and movement are applied to every member. + * + *

Viewers added to the group are added to every member, including members + * added later. The group does not batch packets itself; packet renderers + * that share a {@code VirtualEntityManager} may wrap calls in + * {@code VirtualEntityManager#bundle} for atomic updates.

+ */ +public class ShapeGroup implements Shape { + + private final List members = new ArrayList<>(); + private final Set viewers = new LinkedHashSet<>(); + private boolean spawned; + private int interpolationDuration; + private int teleportDuration; + + public ShapeGroup() { + } + + public ShapeGroup(List shapes) { + for (Shape shape : shapes) { + add(shape); + } + } + + /** Adds a member, applying the group's viewers and spawning it when the group is spawned. */ + public ShapeGroup add(Shape shape) { + Objects.requireNonNull(shape, "shape"); + members.add(shape); + for (UUID viewer : viewers) { + shape.addViewer(viewer); + } + if (spawned && !shape.isSpawned()) { + shape.spawn(); + } + return this; + } + + /** Removes a member from the group and from the world. */ + public boolean remove(Shape shape) { + if (!members.remove(shape)) { + return false; + } + shape.remove(); + return true; + } + + /** Returns a copy of the members in insertion order. */ + public List getMembers() { + return new ArrayList<>(members); + } + + @Override + public void spawn() { + if (spawned) { + return; + } + for (Shape shape : members) { + shape.spawn(); + } + spawned = true; + } + + @Override + public void remove() { + for (Shape shape : members) { + shape.remove(); + } + spawned = false; + } + + @Override + public boolean isSpawned() { + return spawned; + } + + @Override + public void addViewer(UUID playerUUID) { + viewers.add(playerUUID); + for (Shape shape : members) { + shape.addViewer(playerUUID); + } + } + + @Override + public void removeViewer(UUID playerUUID) { + viewers.remove(playerUUID); + for (Shape shape : members) { + shape.removeViewer(playerUUID); + } + } + + @Override + public Set getViewerUUIDs() { + return new LinkedHashSet<>(viewers); + } + + @Override + public List getEntityUUIDs() { + List uuids = new ArrayList<>(); + for (Shape shape : members) { + uuids.addAll(shape.getEntityUUIDs()); + } + return uuids; + } + + @Override + public int getEntityCount() { + int count = 0; + for (Shape shape : members) { + count += shape.getEntityCount(); + } + return count; + } + + @Override + public void teleportOrigin(double x, double y, double z) { + for (Shape shape : members) { + shape.teleportOrigin(x, y, z); + } + } + + @Override + public void setColor(int argb) { + for (Shape shape : members) { + shape.setColor(argb); + } + } + + /** Returns the color of the first member. */ + @Override + public int getColor() { + if (members.isEmpty()) { + throw new IllegalStateException("Empty shape group has no color"); + } + return members.get(0).getColor(); + } + + @Override + public void setInterpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + interpolationDuration = ticks; + for (Shape shape : members) { + shape.setInterpolationDuration(ticks); + } + } + + @Override + public int getInterpolationDuration() { + return interpolationDuration; + } + + @Override + public void setTeleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + teleportDuration = ticks; + for (Shape shape : members) { + shape.setTeleportDuration(ticks); + } + } + + @Override + public int getTeleportDuration() { + return teleportDuration; + } + + @Override + public void translate(double dx, double dy, double dz) { + for (Shape shape : members) { + shape.translate(dx, dy, dz); + } + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeStyle.java b/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeStyle.java new file mode 100644 index 0000000..5507741 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/ShapeStyle.java @@ -0,0 +1,86 @@ +package dev.twme.textdisplayshape.shape; + +/** + * Reusable appearance and animation settings that can be applied to any + * {@link ShapeBuilder}, and that composite helpers such as box outlines pass + * to each of their parts. + * + * @param argbColor background color in ARGB format + * @param doubleSided whether a back face is rendered + * @param blockLight block light override (0-15) + * @param skyLight sky light override (0-15) + * @param seeThrough whether the shape renders through blocks + * @param viewRange Text Display view range multiplier + * @param rootAnchor whether packet shapes ride one root entity + * @param interpolationDuration ticks used to animate geometry and color updates + * @param teleportDuration ticks used to animate {@link Shape#translate} + */ +public record ShapeStyle( + int argbColor, + boolean doubleSided, + int blockLight, + int skyLight, + boolean seeThrough, + float viewRange, + boolean rootAnchor, + int interpolationDuration, + int teleportDuration +) { + + /** The defaults used by every builder. */ + public static final ShapeStyle DEFAULT = new ShapeStyle( + 0xC8FF6464, false, 15, 15, true, 1.0f, false, 0, 0); + + public ShapeStyle { + if (blockLight < 0 || blockLight > 15 || skyLight < 0 || skyLight > 15) { + throw new IllegalArgumentException("Light levels must be between 0 and 15"); + } + if (interpolationDuration < 0) { + throw new IllegalArgumentException("interpolationDuration must not be negative"); + } + if (teleportDuration < 0 || teleportDuration > Shape.MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException( + "teleportDuration must be between 0 and " + Shape.MAX_TELEPORT_DURATION); + } + } + + public ShapeStyle withColor(int argb) { + return new ShapeStyle(argb, doubleSided, blockLight, skyLight, seeThrough, viewRange, + rootAnchor, interpolationDuration, teleportDuration); + } + + public ShapeStyle withDoubleSided(boolean value) { + return new ShapeStyle(argbColor, value, blockLight, skyLight, seeThrough, viewRange, + rootAnchor, interpolationDuration, teleportDuration); + } + + public ShapeStyle withBrightness(int block, int sky) { + return new ShapeStyle(argbColor, doubleSided, block, sky, seeThrough, viewRange, + rootAnchor, interpolationDuration, teleportDuration); + } + + public ShapeStyle withSeeThrough(boolean value) { + return new ShapeStyle(argbColor, doubleSided, blockLight, skyLight, value, viewRange, + rootAnchor, interpolationDuration, teleportDuration); + } + + public ShapeStyle withViewRange(float value) { + return new ShapeStyle(argbColor, doubleSided, blockLight, skyLight, seeThrough, value, + rootAnchor, interpolationDuration, teleportDuration); + } + + public ShapeStyle withRootAnchor(boolean value) { + return new ShapeStyle(argbColor, doubleSided, blockLight, skyLight, seeThrough, viewRange, + value, interpolationDuration, teleportDuration); + } + + public ShapeStyle withInterpolationDuration(int ticks) { + return new ShapeStyle(argbColor, doubleSided, blockLight, skyLight, seeThrough, viewRange, + rootAnchor, ticks, teleportDuration); + } + + public ShapeStyle withTeleportDuration(int ticks) { + return new ShapeStyle(argbColor, doubleSided, blockLight, skyLight, seeThrough, viewRange, + rootAnchor, interpolationDuration, ticks); + } +} diff --git a/api/src/main/java/dev/twme/textdisplayshape/shape/TriangleShape.java b/api/src/main/java/dev/twme/textdisplayshape/shape/TriangleShape.java new file mode 100644 index 0000000..87b7fe4 --- /dev/null +++ b/api/src/main/java/dev/twme/textdisplayshape/shape/TriangleShape.java @@ -0,0 +1,16 @@ +package dev.twme.textdisplayshape.shape; + +import org.joml.Vector3f; + +/** A triangle whose vertices can move after it has spawned. */ +public interface TriangleShape extends Shape { + + Vector3f getPoint1(); + + Vector3f getPoint2(); + + Vector3f getPoint3(); + + /** Moves all three vertices, animated when interpolation is enabled. */ + void setPoints(Vector3f point1, Vector3f point2, Vector3f point3); +} diff --git a/api/src/test/java/dev/twme/textdisplayshape/geometry/ShapeGeometryTest.java b/api/src/test/java/dev/twme/textdisplayshape/geometry/ShapeGeometryTest.java new file mode 100644 index 0000000..97b463d --- /dev/null +++ b/api/src/test/java/dev/twme/textdisplayshape/geometry/ShapeGeometryTest.java @@ -0,0 +1,91 @@ +package dev.twme.textdisplayshape.geometry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import dev.twme.textdisplayshape.util.TextDisplayUtil; + +class ShapeGeometryTest { + + @Test + void entityCountsMatchEachShape() { + Vector3f a = new Vector3f(0, 0, 0); + Vector3f b = new Vector3f(2, 0, 0); + Vector3f c = new Vector3f(0, 2, 0); + assertEquals(1, ShapeGeometry.line(a, b, 0.1f, 0f, false).size()); + assertEquals(2, ShapeGeometry.line(a, b, 0.1f, 0f, true).size()); + assertEquals(3, ShapeGeometry.triangle(a, b, c, false).size()); + assertEquals(6, ShapeGeometry.triangle(a, b, c, true).size()); + assertEquals(1, ShapeGeometry.parallelogram(a, b, c, false).size()); + assertEquals(2, ShapeGeometry.parallelogram(a, b, c, true).size()); + assertEquals(2, ShapeGeometry.polyline(List.of(a, b, c), 0.1f, 0f, false, false).size()); + assertEquals(3, ShapeGeometry.polyline(List.of(a, b, c), 0.1f, 0f, true, false).size()); + assertEquals(1, ShapeGeometry.polyline(List.of(a, b), 0.1f, 0f, true, false).size(), + "two points cannot form a closing segment"); + assertEquals(0, ShapeGeometry.polyline(List.of(a), 0.1f, 0f, false, false).size()); + } + + @Test + void lineTransformReproducesTheLineMatrix() { + Vector3f from = new Vector3f(1.5f, 2f, -3f); + Vector3f to = new Vector3f(4f, 5.5f, 1f); + Matrix4f expected = TextDisplayUtil.textDisplayLine(from, to, 0.2f, 0.3f); + Matrix4f actual = ShapeGeometry.line(from, to, 0.2f, 0.3f, false).get(0).toMatrix(); + assertMatrixEquals(expected, actual); + } + + @Test + void relativeToOnlyShiftsTranslation() { + DisplayTransform transform = ShapeGeometry.parallelogram( + new Vector3f(10, 20, 30), new Vector3f(11, 20, 30), new Vector3f(10, 21, 30), false).get(0); + DisplayTransform relative = transform.relativeTo(10, 20, 30); + assertTrue(relative.translation().distance(transform.translation().sub(10, 20, 30)) < 1e-5f); + assertEquals(transform.scale(), relative.scale()); + assertEquals(transform.leftRotation(), relative.leftRotation()); + } + + @Test + void invalidGeometryIsRejected() { + Vector3f point = new Vector3f(1, 1, 1); + assertThrows(IllegalArgumentException.class, + () -> ShapeGeometry.line(point, new Vector3f(point), 0.1f, 0f, false)); + } + + @Test + void boxEdgesAndOutwardFaces() { + Vector3f min = new Vector3f(0, 0, 0); + Vector3f max = new Vector3f(1, 2, 3); + List edges = BoxGeometry.edges(max, min); + assertEquals(12, edges.size()); + float total = 0; + for (BoxGeometry.Edge edge : edges) { + total += edge.from().distance(edge.to()); + } + assertEquals(4 * (1 + 2 + 3), total, 1e-5f); + + Vector3f center = new Vector3f(0.5f, 1f, 1.5f); + List faces = BoxGeometry.faces(min, max); + assertEquals(6, faces.size()); + for (BoxGeometry.Face face : faces) { + Vector3f normal = new Vector3f(face.first()).sub(face.corner()) + .cross(new Vector3f(face.second()).sub(face.corner())); + Vector3f outward = new Vector3f(face.corner()).sub(center); + assertTrue(normal.dot(outward) > 0, "face must point outward: " + face); + } + } + + private static void assertMatrixEquals(Matrix4f expected, Matrix4f actual) { + float[] e = expected.get(new float[16]); + float[] a = actual.get(new float[16]); + for (int index = 0; index < 16; index++) { + assertEquals(e[index], a[index], 1e-4f, "matrix element " + index); + } + } +} diff --git a/api/src/test/java/dev/twme/textdisplayshape/shape/ShapeGroupTest.java b/api/src/test/java/dev/twme/textdisplayshape/shape/ShapeGroupTest.java new file mode 100644 index 0000000..941a4b3 --- /dev/null +++ b/api/src/test/java/dev/twme/textdisplayshape/shape/ShapeGroupTest.java @@ -0,0 +1,133 @@ +package dev.twme.textdisplayshape.shape; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import dev.twme.textdisplayshape.geometry.BoxGeometry; + +class ShapeGroupTest { + + /** Minimal in-memory line used to observe group behavior. */ + static final class FakeLine implements LineShape { + final Set viewers = new HashSet<>(); + boolean spawned; + int color; + int interpolation; + int teleport; + Vector3f start; + Vector3f end; + final List moves = new ArrayList<>(); + + FakeLine(Vector3f start, Vector3f end) { + this.start = new Vector3f(start); + this.end = new Vector3f(end); + } + + @Override public void spawn() { spawned = true; } + @Override public void remove() { spawned = false; } + @Override public boolean isSpawned() { return spawned; } + @Override public void addViewer(UUID id) { viewers.add(id); } + @Override public void removeViewer(UUID id) { viewers.remove(id); } + @Override public Set getViewerUUIDs() { return new HashSet<>(viewers); } + @Override public List getEntityUUIDs() { return spawned ? List.of(UUID.randomUUID()) : List.of(); } + @Override public void teleportOrigin(double x, double y, double z) { } + @Override public void setColor(int argb) { color = argb; } + @Override public int getColor() { return color; } + @Override public void setInterpolationDuration(int ticks) { interpolation = ticks; } + @Override public int getInterpolationDuration() { return interpolation; } + @Override public void setTeleportDuration(int ticks) { teleport = ticks; } + @Override public void translate(double dx, double dy, double dz) { moves.add(new double[]{dx, dy, dz}); } + @Override public Vector3f getStart() { return new Vector3f(start); } + @Override public Vector3f getEnd() { return new Vector3f(end); } + @Override public void setPoints(Vector3f s, Vector3f e) { start = new Vector3f(s); end = new Vector3f(e); } + } + + @Test + void groupPropagatesLifecycleViewersAndAnimationSettings() { + FakeLine first = new FakeLine(new Vector3f(), new Vector3f(1, 0, 0)); + ShapeGroup group = new ShapeGroup(List.of(first)); + UUID viewer = UUID.randomUUID(); + group.addViewer(viewer); + group.spawn(); + + FakeLine late = new FakeLine(new Vector3f(), new Vector3f(0, 1, 0)); + group.add(late); + assertTrue(late.spawned, "members added to a spawned group spawn immediately"); + assertTrue(late.viewers.contains(viewer), "late members inherit the group's viewers"); + assertEquals(2, group.getEntityCount()); + + group.setColor(0x80FF0000); + group.setInterpolationDuration(5); + group.setTeleportDuration(3); + group.translate(1, 2, 3); + for (FakeLine line : List.of(first, late)) { + assertEquals(0x80FF0000, line.color); + assertEquals(5, line.interpolation); + assertEquals(3, line.teleport); + assertEquals(1, line.moves.size()); + } + assertEquals(0x80FF0000, group.getColor()); + + group.remove(); + assertFalse(first.spawned); + assertFalse(group.isSpawned()); + assertThrows(IllegalArgumentException.class, () -> group.setTeleportDuration(60)); + } + + @Test + void boxOutlineMovesEveryEdgeInPlace() { + List created = new ArrayList<>(); + BoxOutline outline = new BoxOutline(new Vector3f(0, 0, 0), new Vector3f(1, 1, 1), edge -> { + FakeLine line = new FakeLine(edge.from(), edge.to()); + created.add(line); + return line; + }); + assertEquals(12, created.size()); + + outline.setBounds(new Vector3f(2, 2, 2), new Vector3f(4, 4, 4)); + List expected = BoxGeometry.edges(new Vector3f(2, 2, 2), new Vector3f(4, 4, 4)); + for (int index = 0; index < 12; index++) { + assertEquals(expected.get(index).from(), created.get(index).start); + assertEquals(expected.get(index).to(), created.get(index).end); + } + assertEquals(new Vector3f(4, 4, 4), outline.getMax()); + } + + @Test + void styleAppliesEverySetting() { + List calls = new ArrayList<>(); + ShapeBuilder builder = new ShapeBuilder<>() { + @Override public ShapeBuilder color(int argb) { calls.add("color" + argb); return this; } + @Override public ShapeBuilder doubleSided(boolean v) { calls.add("double" + v); return this; } + @Override public ShapeBuilder brightness(int b, int s) { calls.add("light" + b + s); return this; } + @Override public ShapeBuilder seeThrough(boolean v) { calls.add("see" + v); return this; } + @Override public ShapeBuilder viewRange(float v) { calls.add("range" + v); return this; } + @Override public ShapeBuilder rootAnchor(boolean v) { calls.add("root" + v); return this; } + @Override public ShapeBuilder interpolationDuration(int t) { calls.add("interp" + t); return this; } + @Override public ShapeBuilder teleportDuration(int t) { calls.add("tp" + t); return this; } + @Override public FakeLine build() { return null; } + }; + builder.style(ShapeStyle.DEFAULT.withColor(7).withInterpolationDuration(4).withTeleportDuration(2) + .withRootAnchor(true).withBrightness(3, 9)); + assertEquals(List.of("color7", "doublefalse", "light39", "seetrue", "range1.0", "roottrue", + "interp4", "tp2"), calls); + } + + @Test + void styleRejectsInvalidValues() { + assertThrows(IllegalArgumentException.class, () -> ShapeStyle.DEFAULT.withBrightness(16, 0)); + assertThrows(IllegalArgumentException.class, () -> ShapeStyle.DEFAULT.withInterpolationDuration(-1)); + assertThrows(IllegalArgumentException.class, () -> ShapeStyle.DEFAULT.withTeleportDuration(60)); + } +} diff --git a/integration/mineflayer/run-e2e.sh b/integration/mineflayer/run-e2e.sh index 6a01624..918d135 100755 --- a/integration/mineflayer/run-e2e.sh +++ b/integration/mineflayer/run-e2e.sh @@ -35,7 +35,9 @@ trap cleanup EXIT mkdir -p "${CACHE_DIR}" "${SERVER_DIR}/plugins" mvn -B -DskipTests install -mvn -B -f "${PROJECT_DIR}/integration/paper-plugin/pom.xml" clean package +project_version="$(mvn -B -q -f "${PROJECT_DIR}/pom.xml" help:evaluate -Dexpression=project.version -DforceStdout)" +mvn -B -f "${PROJECT_DIR}/integration/paper-plugin/pom.xml" clean package \ + -Dtextdisplayshapes.version="${project_version}" paper_metadata="$(curl --fail --silent --show-error --location "${PAPER_API}")" paper_url="$(jq -er '.downloads["server:default"].url' <<<"${paper_metadata}")" diff --git a/integration/mineflayer/test.mjs b/integration/mineflayer/test.mjs index 517bfc3..9984dbc 100644 --- a/integration/mineflayer/test.mjs +++ b/integration/mineflayer/test.mjs @@ -105,6 +105,7 @@ try { packetTrace.push({ name: metadata.name, entityId: data?.entityId }) }) + const originY = bot.entity.position.y const readyMessage = waitForMessage(bot, 'TDS_READY:') bot.chat('/tdstest') const ready = await readyMessage @@ -146,7 +147,52 @@ try { throw new Error(`Expected atomic root-anchor rebase bundle: ${JSON.stringify(packetTrace)}`) } + const keys = bot.registry.entitiesByName.text_display.metadataKeys + const durationIndex = keys.indexOf('transformation_interpolation_duration') + const startDeltaIndex = keys.indexOf('transformation_interpolation_start_delta_ticks') + const scaleIndex = keys.indexOf('scale') + const colorIndex = keys.indexOf('background_color') + const posRotIndex = keys.indexOf('pos_rot_interpolation_duration') + const initialScaleX = entity.metadata[scaleIndex].x + packetTrace.length = 0 + + await waitForMessage(bot, 'TDS_ANIMATED') + await waitForMetadata( + bot, + entity, + current => current.metadata[durationIndex] === 5 && + current.metadata[startDeltaIndex] === 0 && + Math.abs(current.metadata[scaleIndex]?.x - initialScaleX * 2) < 0.01 && + (current.metadata[colorIndex] >>> 0) === 0x7864FF64, + 'interpolated geometry and color update on the same entity' + ) + if (bot.entities[entityId] !== entity) { + throw new Error('Animated update must reuse the existing Text Display entity') + } + const animationBundled = packetTrace.some((packet, index) => + packet.name === 'bundle_delimiter' && + packetTrace[index + 1]?.name === 'entity_metadata' && + packetTrace[index + 1]?.entityId === entityId) + if (!animationBundled) { + throw new Error(`Expected bundled animation update: ${JSON.stringify(packetTrace)}`) + } + + const translated = await waitForMessage(bot, 'TDS_TRANSLATED:') + const anchorId = Number(translated.substring(translated.indexOf('TDS_TRANSLATED:') + 'TDS_TRANSLATED:'.length).trim()) + const anchor = await waitForEntity(bot, anchorId) + await waitForMetadata(bot, anchor, current => current.metadata[posRotIndex] === 3, + 'root anchor teleport duration') + const deadline = Date.now() + timeoutMs + while (Math.abs(anchor.position.y - (originY + 1)) > 0.01) { + if (Date.now() > deadline) { + throw new Error(`Root anchor did not move up by one block: ${anchor.position}`) + } + await new Promise(resolve => setTimeout(resolve, 50)) + } + console.log(JSON.stringify({ + animation: { interpolationDuration: 5, scaleX: entity.metadata[scaleIndex].x }, + translate: { anchorId, teleportDuration: 3, y: anchor.position.y }, entityId, type: entity.name, translation: entity.metadata[translationIndex], diff --git a/integration/paper-plugin/pom.xml b/integration/paper-plugin/pom.xml index 506ab12..09aa1d6 100644 --- a/integration/paper-plugin/pom.xml +++ b/integration/paper-plugin/pom.xml @@ -10,6 +10,8 @@ 25 + + 3.0.1 UTF-8 @@ -32,7 +34,7 @@ dev.twme textdisplayshape-packet - 3.0.0 + ${textdisplayshapes.version} io.papermc.paper diff --git a/integration/paper-plugin/src/main/java/io/github/twme/textdisplayshapes/integration/TextDisplayShapesIntegrationPlugin.java b/integration/paper-plugin/src/main/java/io/github/twme/textdisplayshapes/integration/TextDisplayShapesIntegrationPlugin.java index cc29e1c..8ffd859 100644 --- a/integration/paper-plugin/src/main/java/io/github/twme/textdisplayshapes/integration/TextDisplayShapesIntegrationPlugin.java +++ b/integration/paper-plugin/src/main/java/io/github/twme/textdisplayshapes/integration/TextDisplayShapesIntegrationPlugin.java @@ -79,6 +79,28 @@ public boolean onCommand( player.sendMessage("TDS_RELOCATED"); } }, 20L); + + // Animated geometry update: the existing entity is reused and the + // client interpolates to the longer line over five ticks. + getServer().getScheduler().runTaskLater(this, () -> { + if (line.isSpawned()) { + line.setInterpolationDuration(5); + line.setPoints( + new Vector3f((float) origin.getX() + 2, (float) origin.getY(), (float) origin.getZ()), + new Vector3f((float) origin.getX() + 6, (float) origin.getY(), (float) origin.getZ())); + line.setColor(Color.fromARGB(120, 100, 255, 100)); + player.sendMessage("TDS_ANIMATED"); + } + }, 40L); + + // Animated movement: only the root anchor moves, with a teleport duration. + getServer().getScheduler().runTaskLater(this, () -> { + if (line.isSpawned()) { + line.setTeleportDuration(3); + line.translate(0, 1, 0); + player.sendMessage("TDS_TRANSLATED:" + line.getRootAnchor().entityId()); + } + }, 60L); return true; } } diff --git a/packet/pom.xml b/packet/pom.xml index c1d5848..d9247e0 100644 --- a/packet/pom.xml +++ b/packet/pom.xml @@ -92,6 +92,12 @@ v0.9.0 compile + + org.mockito + mockito-core + 5.23.0 + test + org.junit.jupiter junit-jupiter diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/AbstractPacketShape.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/AbstractPacketShape.java new file mode 100644 index 0000000..e6c362b --- /dev/null +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/AbstractPacketShape.java @@ -0,0 +1,427 @@ +package dev.twme.textdisplayshape.packet; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Color; +import org.bukkit.Location; + +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.shape.Shape; +import dev.twme.textdisplayshape.shape.ShapeBuilder; +import dev.twme.textdisplayshape.shape.ShapeStyle; +import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import io.github.twme.virtualentities.VirtualEntity; +import io.github.twme.virtualentities.VirtualEntityManager; + +/** + * Shared lifecycle, viewer, animation, and movement handling for packet + * shapes. Subclasses only describe their geometry as world-space + * {@link DisplayTransform}s. + * + *

Updates reuse the existing virtual entities and are sent in one + * VirtualEntities bundle, so every part of a shape changes in the same frame + * and animates over {@link #getInterpolationDuration()} ticks.

+ */ +public abstract class AbstractPacketShape implements Shape { + + private Location origin; + private int argbColor; + private final boolean doubleSided; + private final int blockLight; + private final int skyLight; + private final boolean seeThrough; + private final float viewRange; + private final boolean rootAnchorEnabled; + private int interpolationDuration; + private int teleportDuration; + private final VirtualEntityManager entityManager; + + private final List entities = new ArrayList<>(); + private final Set viewerUUIDs = new HashSet<>(); + private VirtualEntity rootAnchor; + private boolean spawned; + + protected AbstractPacketShape(Builder builder) { + this.origin = Objects.requireNonNull(builder.origin, "origin").clone(); + this.argbColor = builder.argbColor; + this.doubleSided = builder.doubleSided; + this.blockLight = builder.blockLight; + this.skyLight = builder.skyLight; + this.seeThrough = builder.seeThrough; + this.viewRange = builder.viewRange; + this.rootAnchorEnabled = builder.rootAnchorEnabled; + this.interpolationDuration = builder.interpolationDuration; + this.teleportDuration = builder.teleportDuration; + this.entityManager = builder.entityManager; + } + + /** World-space transforms for every entity of the current geometry. */ + protected abstract List computeTransforms(); + + /** Shifts the stored geometry by a world-space offset. */ + protected abstract void offsetGeometry(float dx, float dy, float dz); + + protected final boolean isDoubleSided() { + return doubleSided; + } + + /** The current logical origin of the shape's entities. */ + public Location getOrigin() { + return origin.clone(); + } + + // ===================================================================== + // Lifecycle + // ===================================================================== + + @Override + public void spawn() { + if (spawned) { + return; + } + List transforms = computeTransforms(); + if (rootAnchorEnabled && !transforms.isEmpty()) { + rootAnchor = PacketRootAnchorSupport.createRootAnchor(entityManager, origin, viewRange, Set.of()); + VirtualTextDisplaySupport.setTeleportDuration(rootAnchor, teleportDuration); + } + for (DisplayTransform transform : transforms) { + createEntity(transform); + } + if (rootAnchor != null) { + VirtualTextDisplaySupport.addViewers(entityManager, rootAnchor, viewerUUIDs); + } + spawned = true; + } + + private void createEntity(DisplayTransform transform) { + DisplayTransform relative = transform.relativeTo(origin.getX(), origin.getY(), origin.getZ()); + VirtualEntity entity = VirtualTextDisplaySupport.createTextDisplay( + entityManager, + origin, + viewerUUIDs, + virtualEntity -> { + VirtualTextDisplaySupport.configureTextDisplay( + virtualEntity, argbColor, seeThrough, blockLight, skyLight, viewRange); + VirtualTextDisplaySupport.setTransform(virtualEntity, relative); + if (!rootAnchorEnabled) { + VirtualTextDisplaySupport.setTeleportDuration(virtualEntity, teleportDuration); + } + } + ); + entities.add(entity); + if (rootAnchor != null) { + PacketRootAnchorSupport.attachPassenger(rootAnchor, entity); + } + } + + @Override + public void remove() { + for (VirtualEntity entity : entities) { + entity.remove(); + } + entities.clear(); + if (rootAnchor != null) { + rootAnchor.remove(); + rootAnchor = null; + } + spawned = false; + } + + @Override + public boolean isSpawned() { + return spawned; + } + + // ===================================================================== + // Viewers + // ===================================================================== + + @Override + public void addViewer(UUID playerUUID) { + viewerUUIDs.add(playerUUID); + if (spawned) { + for (VirtualEntity entity : entities) { + VirtualTextDisplaySupport.addViewer(entityManager, entity, playerUUID); + } + if (rootAnchor != null) { + VirtualTextDisplaySupport.addViewer(entityManager, rootAnchor, playerUUID); + } + } + } + + @Override + public void removeViewer(UUID playerUUID) { + viewerUUIDs.remove(playerUUID); + if (spawned) { + if (rootAnchor != null) { + rootAnchor.removeViewer(playerUUID); + } + for (VirtualEntity entity : entities) { + entity.removeViewer(playerUUID); + } + } + } + + @Override + public Set getViewerUUIDs() { + return new HashSet<>(viewerUUIDs); + } + + @Override + public List getEntityUUIDs() { + List uuids = new ArrayList<>(entities.size()); + for (VirtualEntity entity : entities) { + uuids.add(entity.uuid()); + } + return uuids; + } + + @Override + public int getEntityCount() { + return entities.size(); + } + + /** The Text Display entities of this shape, excluding the root anchor. */ + public List getEntities() { + return new ArrayList<>(entities); + } + + /** The invisible root anchor, or {@code null} when root-anchor mode is off or not spawned. */ + public VirtualEntity getRootAnchor() { + return rootAnchor; + } + + // ===================================================================== + // Updates + // ===================================================================== + + /** + * Sends new geometry. Existing entities are updated in place and animate; + * entities needed by a larger geometry appear immediately, and surplus + * entities are removed. + */ + protected final void applyTransforms(List transforms) { + if (!spawned) { + return; + } + entityManager.bundle(() -> { + int reused = Math.min(entities.size(), transforms.size()); + for (int index = 0; index < reused; index++) { + VirtualEntity entity = entities.get(index); + VirtualTextDisplaySupport.setTransform(entity, + transforms.get(index).relativeTo(origin.getX(), origin.getY(), origin.getZ())); + VirtualTextDisplaySupport.setInterpolation(entity, interpolationDuration); + entity.syncMetadata(); + } + while (entities.size() > transforms.size()) { + entities.remove(entities.size() - 1).remove(); + } + if (rootAnchorEnabled && rootAnchor == null && transforms.size() > reused) { + rootAnchor = PacketRootAnchorSupport.createRootAnchor(entityManager, origin, viewRange, Set.of()); + VirtualTextDisplaySupport.setTeleportDuration(rootAnchor, teleportDuration); + VirtualTextDisplaySupport.addViewers(entityManager, rootAnchor, viewerUUIDs); + } + for (int index = reused; index < transforms.size(); index++) { + createEntity(transforms.get(index)); + } + }); + } + + @Override + public void setColor(int argb) { + this.argbColor = argb; + if (!spawned) { + return; + } + entityManager.bundle(() -> { + for (VirtualEntity entity : entities) { + VirtualTextDisplaySupport.setBackgroundColor(entity, argb); + VirtualTextDisplaySupport.setInterpolation(entity, interpolationDuration); + entity.syncMetadata(); + } + }); + } + + @Override + public int getColor() { + return argbColor; + } + + /** Convenience overload of {@link #setColor(int)}. */ + public void setColor(Color color) { + setColor(color.asARGB()); + } + + @Override + public void setInterpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + this.interpolationDuration = ticks; + } + + @Override + public int getInterpolationDuration() { + return interpolationDuration; + } + + @Override + public void setTeleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + this.teleportDuration = ticks; + } + + @Override + public int getTeleportDuration() { + return teleportDuration; + } + + @Override + public void translate(double dx, double dy, double dz) { + offsetGeometry((float) dx, (float) dy, (float) dz); + Location newOrigin = origin.clone().add(dx, dy, dz); + origin = newOrigin; + if (!spawned) { + return; + } + var target = SpigotConversionUtil.fromBukkitLocation(newOrigin); + entityManager.bundle(() -> { + for (VirtualEntity mover : movers()) { + VirtualTextDisplaySupport.setTeleportDuration(mover, teleportDuration); + mover.syncMetadata(); + mover.teleport(target); + } + }); + } + + private List movers() { + return rootAnchor != null ? List.of(rootAnchor) : entities; + } + + @Override + public void teleportOrigin(double x, double y, double z) { + if (!spawned) { + return; + } + Location newOrigin = new Location(origin.getWorld(), x, y, z); + if (rootAnchor != null) { + PacketRootAnchorSupport.teleportRootAnchor(entityManager, rootAnchor, entities, origin, newOrigin); + } else { + VirtualTextDisplaySupport.rebaseEntities(entityManager, entities, origin, newOrigin); + } + origin = newOrigin.clone(); + } + + // ===================================================================== + // Builder + // ===================================================================== + + /** + * Shared builder settings. + * + * @param the shape type + * @param the concrete builder type + */ + public abstract static class Builder> + implements ShapeBuilder { + private final Location origin; + private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); + private boolean doubleSided; + private int blockLight = 15; + private int skyLight = 15; + private boolean seeThrough = true; + private float viewRange = 1.0f; + private boolean rootAnchorEnabled; + private int interpolationDuration; + private int teleportDuration; + private VirtualEntityManager entityManager = PacketEntityManagers.defaultManager(); + + protected Builder(Location origin) { + this.origin = origin; + } + + @SuppressWarnings("unchecked") + protected final B self() { + return (B) this; + } + + B withEntityManager(VirtualEntityManager manager) { + this.entityManager = Objects.requireNonNull(manager, "entityManager"); + return self(); + } + + public B color(Color color) { + this.argbColor = color.asARGB(); + return self(); + } + + @Override + public B color(int argb) { + this.argbColor = argb; + return self(); + } + + @Override + public B doubleSided(boolean value) { + this.doubleSided = value; + return self(); + } + + @Override + public B brightness(int blockLight, int skyLight) { + VirtualTextDisplaySupport.packBrightness(blockLight, skyLight); + this.blockLight = blockLight; + this.skyLight = skyLight; + return self(); + } + + @Override + public B seeThrough(boolean value) { + this.seeThrough = value; + return self(); + } + + @Override + public B viewRange(float value) { + this.viewRange = value; + return self(); + } + + @Override + public B rootAnchor(boolean value) { + this.rootAnchorEnabled = value; + return self(); + } + + @Override + public B interpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + this.interpolationDuration = ticks; + return self(); + } + + @Override + public B teleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + this.teleportDuration = ticks; + return self(); + } + + @Override + public B style(ShapeStyle style) { + ShapeBuilder.super.style(style); + return self(); + } + } +} diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketLine.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketLine.java index bd9f600..0607cd5 100644 --- a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketLine.java +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketLine.java @@ -1,210 +1,74 @@ package dev.twme.textdisplayshape.packet; -import io.github.twme.virtualentities.VirtualEntity; -import io.github.twme.virtualentities.VirtualEntityManager; -import org.bukkit.Color; +import java.util.List; + import org.bukkit.Location; -import org.joml.Matrix4f; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TextDisplayUtil; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.LineShape; /** * Line implementation using VirtualEntities and PacketEvents. * Only sends packets to specified viewer players, never creating server entities. */ -public class PacketLine implements Shape { - private Location origin; +public class PacketLine extends AbstractPacketShape implements LineShape { private final Vector3f p1; private final Vector3f p2; private final float thickness; private final float roll; - private final int argbColor; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - private final boolean rootAnchorEnabled; - private final VirtualEntityManager entityManager; - - private final List entities = new ArrayList<>(); - private final Set viewerUUIDs = new HashSet<>(); - private VirtualEntity rootAnchor; - private boolean spawned; private PacketLine(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); this.thickness = builder.thickness; this.roll = builder.roll; - this.argbColor = builder.argbColor; - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; - this.rootAnchorEnabled = builder.rootAnchorEnabled; - this.entityManager = builder.entityManager; - } - - @Override - public void spawn() { - if (spawned) { - return; - } - if (rootAnchorEnabled) { - rootAnchor = PacketRootAnchorSupport.createRootAnchor(entityManager, origin, viewRange, Set.of()); - } - createVirtualEntity(TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll)); - if (doubleSided) { - createVirtualEntity(TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll)); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewers(entityManager, rootAnchor, viewerUUIDs); - } - spawned = true; - } - - private void createVirtualEntity(Matrix4f matrix) { - Matrix4f adjusted = new Matrix4f() - .translate((float) -origin.getX(), (float) -origin.getY(), (float) -origin.getZ()) - .mul(matrix); - VirtualEntity entity = VirtualTextDisplaySupport.createTextDisplay( - entityManager, - origin, - viewerUUIDs, - virtualEntity -> { - VirtualTextDisplaySupport.configureTextDisplay( - virtualEntity, - argbColor, - seeThrough, - blockLight, - skyLight, - viewRange - ); - VirtualTextDisplaySupport.setTransformFromMatrix(virtualEntity, adjusted); - } - ); - entities.add(entity); - if (rootAnchorEnabled) { - PacketRootAnchorSupport.attachPassenger(rootAnchor, entity); - } } @Override - public void remove() { - for (VirtualEntity entity : entities) { - entity.remove(); - } - entities.clear(); - if (rootAnchor != null) { - rootAnchor.remove(); - rootAnchor = null; - } - spawned = false; - } - - @Override - public boolean isSpawned() { - return spawned; + protected List computeTransforms() { + return ShapeGeometry.line(p1, p2, thickness, roll, isDoubleSided()); } @Override - public void addViewer(UUID playerUUID) { - viewerUUIDs.add(playerUUID); - if (spawned) { - for (VirtualEntity entity : entities) { - VirtualTextDisplaySupport.addViewer(entityManager, entity, playerUUID); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewer(entityManager, rootAnchor, playerUUID); - } - } + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); } @Override - public void removeViewer(UUID playerUUID) { - viewerUUIDs.remove(playerUUID); - if (spawned) { - if (rootAnchor != null) { - rootAnchor.removeViewer(playerUUID); - } - for (VirtualEntity entity : entities) { - entity.removeViewer(playerUUID); - } - } + public Vector3f getStart() { + return new Vector3f(p1); } @Override - public Set getViewerUUIDs() { - return new HashSet<>(viewerUUIDs); + public Vector3f getEnd() { + return new Vector3f(p2); } @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (VirtualEntity entity : entities) { - uuids.add(entity.uuid()); - } - return uuids; - } - - public List getEntities() { - return new ArrayList<>(entities); - } - - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) { - return; - } - Location newOrigin = new Location(origin.getWorld(), x, y, z); - if (rootAnchorEnabled && rootAnchor != null) { - PacketRootAnchorSupport.teleportRootAnchor(entityManager, rootAnchor, entities, origin, newOrigin); - } else { - VirtualTextDisplaySupport.rebaseEntities(entityManager, entities, origin, newOrigin); - } - origin = newOrigin.clone(); + public void setPoints(Vector3f start, Vector3f end) { + List transforms = ShapeGeometry.line(start, end, thickness, roll, isDoubleSided()); + p1.set(start); + p2.set(end); + applyTransforms(transforms); } - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractPacketShape.Builder { private final Vector3f p1; private final Vector3f p2; private final float thickness; private float roll; - private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); - private boolean doubleSided; - private int blockLight = 15; - private int skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; - private boolean rootAnchorEnabled; - private VirtualEntityManager entityManager = PacketEntityManagers.defaultManager(); public Builder(Location origin, Vector3f p1, Vector3f p2, float thickness) { - this.origin = origin; + super(origin); this.p1 = p1; this.p2 = p2; this.thickness = thickness; } - Builder withEntityManager(VirtualEntityManager entityManager) { - this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); - return this; - } - public Builder roll(float roll) { this.roll = roll; return this; @@ -215,48 +79,6 @@ public Builder rollDegrees(float degrees) { return this; } - public Builder color(Color color) { - this.argbColor = color.asARGB(); - return this; - } - - @Override - public Builder color(int argb) { - this.argbColor = argb; - return this; - } - - @Override - public Builder doubleSided(boolean value) { - this.doubleSided = value; - return this; - } - - @Override - public Builder brightness(int blockLight, int skyLight) { - this.blockLight = blockLight; - this.skyLight = skyLight; - return this; - } - - @Override - public Builder seeThrough(boolean value) { - this.seeThrough = value; - return this; - } - - @Override - public Builder viewRange(float value) { - this.viewRange = value; - return this; - } - - @Override - public Builder rootAnchor(boolean value) { - this.rootAnchorEnabled = value; - return this; - } - @Override public PacketLine build() { return new PacketLine(this); diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketParallelogram.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketParallelogram.java index ad90d74..2335282 100644 --- a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketParallelogram.java +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketParallelogram.java @@ -1,251 +1,75 @@ package dev.twme.textdisplayshape.packet; -import io.github.twme.virtualentities.VirtualEntity; -import io.github.twme.virtualentities.VirtualEntityManager; -import org.bukkit.Color; +import java.util.List; + import org.bukkit.Location; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TRSResult; -import dev.twme.textdisplayshape.util.TextDisplayUtil; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.ParallelogramShape; /** Parallelogram implementation using VirtualEntities and PacketEvents. */ -public class PacketParallelogram implements Shape { - private Location origin; +public class PacketParallelogram extends AbstractPacketShape implements ParallelogramShape { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private final int argbColor; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - private final boolean rootAnchorEnabled; - private final VirtualEntityManager entityManager; - - private final List entities = new ArrayList<>(); - private final Set viewerUUIDs = new HashSet<>(); - private VirtualEntity rootAnchor; - private boolean spawned; private PacketParallelogram(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; - this.p3 = builder.p3; - this.argbColor = builder.argbColor; - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; - this.rootAnchorEnabled = builder.rootAnchorEnabled; - this.entityManager = builder.entityManager; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); + this.p3 = new Vector3f(builder.p3); } @Override - public void spawn() { - if (spawned) { - return; - } - if (rootAnchorEnabled) { - rootAnchor = PacketRootAnchorSupport.createRootAnchor(entityManager, origin, viewRange, Set.of()); - } - createVirtualEntity(TextDisplayUtil.computeParallelogramTRS(p1, p2, p3)); - if (doubleSided) { - createVirtualEntity(TextDisplayUtil.computeParallelogramTRS(p1, p3, p2)); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewers(entityManager, rootAnchor, viewerUUIDs); - } - spawned = true; - } - - private void createVirtualEntity(TRSResult transform) { - Vector3f adjustedTranslation = new Vector3f(transform.translation()) - .sub((float) origin.getX(), (float) origin.getY(), (float) origin.getZ()); - VirtualEntity entity = VirtualTextDisplaySupport.createTextDisplay( - entityManager, - origin, - viewerUUIDs, - virtualEntity -> { - VirtualTextDisplaySupport.configureTextDisplay( - virtualEntity, - argbColor, - seeThrough, - blockLight, - skyLight, - viewRange - ); - VirtualTextDisplaySupport.setTransform( - virtualEntity, - adjustedTranslation, - transform.scale(), - transform.leftRotation(), - transform.rightRotation() - ); - } - ); - entities.add(entity); - if (rootAnchorEnabled) { - PacketRootAnchorSupport.attachPassenger(rootAnchor, entity); - } + protected List computeTransforms() { + return ShapeGeometry.parallelogram(p1, p2, p3, isDoubleSided()); } @Override - public void remove() { - for (VirtualEntity entity : entities) { - entity.remove(); - } - entities.clear(); - if (rootAnchor != null) { - rootAnchor.remove(); - rootAnchor = null; - } - spawned = false; - } - - @Override - public boolean isSpawned() { - return spawned; + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); + p3.add(dx, dy, dz); } @Override - public void addViewer(UUID playerUUID) { - viewerUUIDs.add(playerUUID); - if (spawned) { - for (VirtualEntity entity : entities) { - VirtualTextDisplaySupport.addViewer(entityManager, entity, playerUUID); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewer(entityManager, rootAnchor, playerUUID); - } - } + public Vector3f getPoint1() { + return new Vector3f(p1); } @Override - public void removeViewer(UUID playerUUID) { - viewerUUIDs.remove(playerUUID); - if (spawned) { - if (rootAnchor != null) { - rootAnchor.removeViewer(playerUUID); - } - for (VirtualEntity entity : entities) { - entity.removeViewer(playerUUID); - } - } + public Vector3f getPoint2() { + return new Vector3f(p2); } @Override - public Set getViewerUUIDs() { - return new HashSet<>(viewerUUIDs); + public Vector3f getPoint3() { + return new Vector3f(p3); } @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (VirtualEntity entity : entities) { - uuids.add(entity.uuid()); - } - return uuids; - } - - public List getEntities() { - return new ArrayList<>(entities); + public void setPoints(Vector3f point1, Vector3f point2, Vector3f point3) { + List transforms = ShapeGeometry.parallelogram(point1, point2, point3, isDoubleSided()); + p1.set(point1); + p2.set(point2); + p3.set(point3); + applyTransforms(transforms); } - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) { - return; - } - Location newOrigin = new Location(origin.getWorld(), x, y, z); - if (rootAnchorEnabled && rootAnchor != null) { - PacketRootAnchorSupport.teleportRootAnchor(entityManager, rootAnchor, entities, origin, newOrigin); - } else { - VirtualTextDisplaySupport.rebaseEntities(entityManager, entities, origin, newOrigin); - } - origin = newOrigin.clone(); - } - - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractPacketShape.Builder { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private int argbColor = Color.fromARGB(150, 100, 50, 150).asARGB(); - private boolean doubleSided; - private int blockLight = 15; - private int skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; - private boolean rootAnchorEnabled; - private VirtualEntityManager entityManager = PacketEntityManagers.defaultManager(); public Builder(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { - this.origin = origin; + super(origin); this.p1 = p1; this.p2 = p2; this.p3 = p3; } - Builder withEntityManager(VirtualEntityManager entityManager) { - this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); - return this; - } - - public Builder color(Color color) { - this.argbColor = color.asARGB(); - return this; - } - - @Override - public Builder color(int argb) { - this.argbColor = argb; - return this; - } - - @Override - public Builder doubleSided(boolean value) { - this.doubleSided = value; - return this; - } - - @Override - public Builder brightness(int blockLight, int skyLight) { - this.blockLight = blockLight; - this.skyLight = skyLight; - return this; - } - - @Override - public Builder seeThrough(boolean value) { - this.seeThrough = value; - return this; - } - - @Override - public Builder viewRange(float value) { - this.viewRange = value; - return this; - } - - @Override - public Builder rootAnchor(boolean value) { - this.rootAnchorEnabled = value; - return this; - } - @Override public PacketParallelogram build() { return new PacketParallelogram(this); diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketPolyline.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketPolyline.java index 2494562..abc4baf 100644 --- a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketPolyline.java +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketPolyline.java @@ -1,233 +1,93 @@ package dev.twme.textdisplayshape.packet; -import io.github.twme.virtualentities.VirtualEntity; -import io.github.twme.virtualentities.VirtualEntityManager; -import org.bukkit.Color; +import java.util.ArrayList; +import java.util.List; + import org.bukkit.Location; -import org.joml.Matrix4f; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TextDisplayUtil; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.PolylineShape; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; - -/** Polyline implementation using VirtualEntities and PacketEvents. */ -public class PacketPolyline implements Shape { - private Location origin; - private final List points; +/** + * Polyline (connected line segments) implementation using VirtualEntities and + * PacketEvents. + */ +public class PacketPolyline extends AbstractPacketShape implements PolylineShape { + private final List points = new ArrayList<>(); private final float thickness; private final float roll; - private final int argbColor; - private final boolean doubleSided; private final boolean closed; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - private final boolean rootAnchorEnabled; - private final VirtualEntityManager entityManager; - - private final List entities = new ArrayList<>(); - private final Set viewerUUIDs = new HashSet<>(); - private VirtualEntity rootAnchor; - private boolean spawned; private PacketPolyline(Builder builder) { - this.origin = builder.origin; - this.points = new ArrayList<>(builder.points); + super(builder); + for (Vector3f point : builder.points) { + points.add(new Vector3f(point)); + } this.thickness = builder.thickness; this.roll = builder.roll; - this.argbColor = builder.argbColor; - this.doubleSided = builder.doubleSided; this.closed = builder.closed; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; - this.rootAnchorEnabled = builder.rootAnchorEnabled; - this.entityManager = builder.entityManager; } @Override - public void spawn() { - if (spawned) { - return; - } - if (points.size() < 2) { - spawned = true; - return; - } - if (rootAnchorEnabled) { - rootAnchor = PacketRootAnchorSupport.createRootAnchor(entityManager, origin, viewRange, Set.of()); - } - for (int index = 0; index < points.size() - 1; index++) { - spawnLineSegment(points.get(index), points.get(index + 1)); - } - if (closed && points.size() > 2) { - spawnLineSegment(points.get(points.size() - 1), points.get(0)); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewers(entityManager, rootAnchor, viewerUUIDs); - } - spawned = true; - } - - private void spawnLineSegment(Vector3f first, Vector3f second) { - createVirtualEntity(TextDisplayUtil.textDisplayLine(first, second, thickness, roll)); - if (doubleSided) { - createVirtualEntity(TextDisplayUtil.textDisplayLine(second, first, thickness, roll + (float) Math.PI)); - } - } - - private void createVirtualEntity(Matrix4f matrix) { - Matrix4f adjusted = new Matrix4f() - .translate((float) -origin.getX(), (float) -origin.getY(), (float) -origin.getZ()) - .mul(matrix); - VirtualEntity entity = VirtualTextDisplaySupport.createTextDisplay( - entityManager, - origin, - viewerUUIDs, - virtualEntity -> { - VirtualTextDisplaySupport.configureTextDisplay( - virtualEntity, - argbColor, - seeThrough, - blockLight, - skyLight, - viewRange - ); - VirtualTextDisplaySupport.setTransformFromMatrix(virtualEntity, adjusted); - } - ); - entities.add(entity); - if (rootAnchorEnabled) { - PacketRootAnchorSupport.attachPassenger(rootAnchor, entity); - } + protected List computeTransforms() { + return ShapeGeometry.polyline(points, thickness, roll, closed, isDoubleSided()); } @Override - public void remove() { - for (VirtualEntity entity : entities) { - entity.remove(); + protected void offsetGeometry(float dx, float dy, float dz) { + for (Vector3f point : points) { + point.add(dx, dy, dz); } - entities.clear(); - if (rootAnchor != null) { - rootAnchor.remove(); - rootAnchor = null; - } - spawned = false; - } - - @Override - public boolean isSpawned() { - return spawned; - } - - @Override - public void addViewer(UUID playerUUID) { - viewerUUIDs.add(playerUUID); - if (spawned) { - for (VirtualEntity entity : entities) { - VirtualTextDisplaySupport.addViewer(entityManager, entity, playerUUID); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewer(entityManager, rootAnchor, playerUUID); - } - } - } - - @Override - public void removeViewer(UUID playerUUID) { - viewerUUIDs.remove(playerUUID); - if (spawned) { - if (rootAnchor != null) { - rootAnchor.removeViewer(playerUUID); - } - for (VirtualEntity entity : entities) { - entity.removeViewer(playerUUID); - } - } - } - - @Override - public Set getViewerUUIDs() { - return new HashSet<>(viewerUUIDs); } @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (VirtualEntity entity : entities) { - uuids.add(entity.uuid()); + public List getPoints() { + List copy = new ArrayList<>(points.size()); + for (Vector3f point : points) { + copy.add(new Vector3f(point)); } - return uuids; - } - - public List getEntities() { - return new ArrayList<>(entities); + return copy; } @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) { - return; - } - Location newOrigin = new Location(origin.getWorld(), x, y, z); - if (rootAnchorEnabled && rootAnchor != null) { - PacketRootAnchorSupport.teleportRootAnchor(entityManager, rootAnchor, entities, origin, newOrigin); - } else { - VirtualTextDisplaySupport.rebaseEntities(entityManager, entities, origin, newOrigin); - } - origin = newOrigin.clone(); + public void setPoints(List newPoints) { + List transforms = + ShapeGeometry.polyline(newPoints, thickness, roll, closed, isDoubleSided()); + points.clear(); + for (Vector3f point : newPoints) { + points.add(new Vector3f(point)); + } + applyTransforms(transforms); } public int getSegmentCount() { if (points.size() < 2) { return 0; } - return closed ? points.size() : points.size() - 1; + return closed && points.size() > 2 ? points.size() : points.size() - 1; } - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractPacketShape.Builder { private final List points; private final float thickness; private float roll; - private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); - private boolean doubleSided; private boolean closed; - private int blockLight = 15; - private int skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; - private boolean rootAnchorEnabled; - private VirtualEntityManager entityManager = PacketEntityManagers.defaultManager(); public Builder(Location origin, List points, float thickness) { - this.origin = origin; + super(origin); this.points = new ArrayList<>(points); this.thickness = thickness; } - Builder withEntityManager(VirtualEntityManager entityManager) { - this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); - return this; - } - public Builder closed(boolean value) { this.closed = value; return this; } - public Builder roll(float value) { - this.roll = value; + public Builder roll(float roll) { + this.roll = roll; return this; } @@ -236,48 +96,6 @@ public Builder rollDegrees(float degrees) { return this; } - public Builder color(Color color) { - this.argbColor = color.asARGB(); - return this; - } - - @Override - public Builder color(int argb) { - this.argbColor = argb; - return this; - } - - @Override - public Builder doubleSided(boolean value) { - this.doubleSided = value; - return this; - } - - @Override - public Builder brightness(int blockLight, int skyLight) { - this.blockLight = blockLight; - this.skyLight = skyLight; - return this; - } - - @Override - public Builder seeThrough(boolean value) { - this.seeThrough = value; - return this; - } - - @Override - public Builder viewRange(float value) { - this.viewRange = value; - return this; - } - - @Override - public Builder rootAnchor(boolean value) { - this.rootAnchorEnabled = value; - return this; - } - @Override public PacketPolyline build() { return new PacketPolyline(this); diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketShapeFactory.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketShapeFactory.java index 76ecf8c..074b0f2 100644 --- a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketShapeFactory.java +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketShapeFactory.java @@ -8,6 +8,10 @@ import org.bukkit.Location; import org.joml.Vector3f; +import dev.twme.textdisplayshape.shape.BoxFaces; +import dev.twme.textdisplayshape.shape.BoxOutline; +import dev.twme.textdisplayshape.shape.ShapeStyle; + import java.util.List; import java.util.Objects; @@ -71,6 +75,40 @@ public PacketParallelogram.Builder parallelogram(Location origin, Vector3f p1, V return new PacketParallelogram.Builder(origin, p1, p2, p3).withEntityManager(entityManager); } + /** + * The twelve edges of an axis-aligned box, sharing one style. Call + * {@link BoxOutline#setBounds} to resize or move it in place. + */ + public BoxOutline boxOutline(Location origin, Vector3f min, Vector3f max, float thickness, ShapeStyle style) { + Objects.requireNonNull(style, "style"); + BoxOutline outline = new BoxOutline(min, max, + edge -> line(origin, edge.from(), edge.to(), thickness).style(style).build()); + outline.setInterpolationDuration(style.interpolationDuration()); + outline.setTeleportDuration(style.teleportDuration()); + return outline; + } + + /** + * The six outward-facing faces of an axis-aligned box, sharing one style. + * Call {@link BoxFaces#setBounds} to resize or move it in place. + */ + public BoxFaces boxFaces(Location origin, Vector3f min, Vector3f max, ShapeStyle style) { + Objects.requireNonNull(style, "style"); + BoxFaces faces = new BoxFaces(min, max, + face -> parallelogram(origin, face.corner(), face.first(), face.second()).style(style).build()); + faces.setInterpolationDuration(style.interpolationDuration()); + faces.setTeleportDuration(style.teleportDuration()); + return faces; + } + + /** + * Runs {@code updates} as one VirtualEntities bundle, so that changes to + * several shapes from this factory appear to viewers in the same frame. + */ + public void batch(Runnable updates) { + entityManager.bundle(updates); + } + @Override public void close() { if (ownsEntityManager) { diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketTriangle.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketTriangle.java index b9e8d5e..a040216 100644 --- a/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketTriangle.java +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/PacketTriangle.java @@ -1,255 +1,75 @@ package dev.twme.textdisplayshape.packet; -import io.github.twme.virtualentities.VirtualEntity; -import io.github.twme.virtualentities.VirtualEntityManager; -import org.bukkit.Color; +import java.util.List; + import org.bukkit.Location; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TRSResult; -import dev.twme.textdisplayshape.util.TextDisplayUtil; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.TriangleShape; /** Triangle implementation using VirtualEntities and PacketEvents. */ -public class PacketTriangle implements Shape { - private Location origin; +public class PacketTriangle extends AbstractPacketShape implements TriangleShape { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private final int argbColor; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - private final boolean rootAnchorEnabled; - private final VirtualEntityManager entityManager; - - private final List entities = new ArrayList<>(); - private final Set viewerUUIDs = new HashSet<>(); - private VirtualEntity rootAnchor; - private boolean spawned; private PacketTriangle(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; - this.p3 = builder.p3; - this.argbColor = builder.argbColor; - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; - this.rootAnchorEnabled = builder.rootAnchorEnabled; - this.entityManager = builder.entityManager; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); + this.p3 = new Vector3f(builder.p3); } @Override - public void spawn() { - if (spawned) { - return; - } - if (rootAnchorEnabled) { - rootAnchor = PacketRootAnchorSupport.createRootAnchor(entityManager, origin, viewRange, Set.of()); - } - for (TRSResult transform : TextDisplayUtil.computeTriangleTRS(p1, p2, p3)) { - createVirtualEntity(transform); - } - if (doubleSided) { - for (TRSResult transform : TextDisplayUtil.computeTriangleTRS(p1, p3, p2)) { - createVirtualEntity(transform); - } - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewers(entityManager, rootAnchor, viewerUUIDs); - } - spawned = true; - } - - private void createVirtualEntity(TRSResult transform) { - Vector3f adjustedTranslation = new Vector3f(transform.translation()) - .sub((float) origin.getX(), (float) origin.getY(), (float) origin.getZ()); - VirtualEntity entity = VirtualTextDisplaySupport.createTextDisplay( - entityManager, - origin, - viewerUUIDs, - virtualEntity -> { - VirtualTextDisplaySupport.configureTextDisplay( - virtualEntity, - argbColor, - seeThrough, - blockLight, - skyLight, - viewRange - ); - VirtualTextDisplaySupport.setTransform( - virtualEntity, - adjustedTranslation, - transform.scale(), - transform.leftRotation(), - transform.rightRotation() - ); - } - ); - entities.add(entity); - if (rootAnchorEnabled) { - PacketRootAnchorSupport.attachPassenger(rootAnchor, entity); - } + protected List computeTransforms() { + return ShapeGeometry.triangle(p1, p2, p3, isDoubleSided()); } @Override - public void remove() { - for (VirtualEntity entity : entities) { - entity.remove(); - } - entities.clear(); - if (rootAnchor != null) { - rootAnchor.remove(); - rootAnchor = null; - } - spawned = false; - } - - @Override - public boolean isSpawned() { - return spawned; - } - - @Override - public void addViewer(UUID playerUUID) { - viewerUUIDs.add(playerUUID); - if (spawned) { - for (VirtualEntity entity : entities) { - VirtualTextDisplaySupport.addViewer(entityManager, entity, playerUUID); - } - if (rootAnchor != null) { - VirtualTextDisplaySupport.addViewer(entityManager, rootAnchor, playerUUID); - } - } + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); + p3.add(dx, dy, dz); } @Override - public void removeViewer(UUID playerUUID) { - viewerUUIDs.remove(playerUUID); - if (spawned) { - if (rootAnchor != null) { - rootAnchor.removeViewer(playerUUID); - } - for (VirtualEntity entity : entities) { - entity.removeViewer(playerUUID); - } - } + public Vector3f getPoint1() { + return new Vector3f(p1); } @Override - public Set getViewerUUIDs() { - return new HashSet<>(viewerUUIDs); + public Vector3f getPoint2() { + return new Vector3f(p2); } @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (VirtualEntity entity : entities) { - uuids.add(entity.uuid()); - } - return uuids; - } - - public List getEntities() { - return new ArrayList<>(entities); + public Vector3f getPoint3() { + return new Vector3f(p3); } @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) { - return; - } - Location newOrigin = new Location(origin.getWorld(), x, y, z); - if (rootAnchorEnabled && rootAnchor != null) { - PacketRootAnchorSupport.teleportRootAnchor(entityManager, rootAnchor, entities, origin, newOrigin); - } else { - VirtualTextDisplaySupport.rebaseEntities(entityManager, entities, origin, newOrigin); - } - origin = newOrigin.clone(); + public void setPoints(Vector3f point1, Vector3f point2, Vector3f point3) { + List transforms = ShapeGeometry.triangle(point1, point2, point3, isDoubleSided()); + p1.set(point1); + p2.set(point2); + p3.set(point3); + applyTransforms(transforms); } - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractPacketShape.Builder { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private int argbColor = Color.fromARGB(150, 50, 100, 100).asARGB(); - private boolean doubleSided; - private int blockLight = 15; - private int skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; - private boolean rootAnchorEnabled; - private VirtualEntityManager entityManager = PacketEntityManagers.defaultManager(); public Builder(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { - this.origin = origin; + super(origin); this.p1 = p1; this.p2 = p2; this.p3 = p3; } - Builder withEntityManager(VirtualEntityManager entityManager) { - this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); - return this; - } - - public Builder color(Color color) { - this.argbColor = color.asARGB(); - return this; - } - - @Override - public Builder color(int argb) { - this.argbColor = argb; - return this; - } - - @Override - public Builder doubleSided(boolean value) { - this.doubleSided = value; - return this; - } - - @Override - public Builder brightness(int blockLight, int skyLight) { - this.blockLight = blockLight; - this.skyLight = skyLight; - return this; - } - - @Override - public Builder seeThrough(boolean value) { - this.seeThrough = value; - return this; - } - - @Override - public Builder viewRange(float value) { - this.viewRange = value; - return this; - } - - @Override - public Builder rootAnchor(boolean value) { - this.rootAnchorEnabled = value; - return this; - } - @Override public PacketTriangle build() { return new PacketTriangle(this); diff --git a/packet/src/main/java/dev/twme/textdisplayshape/packet/VirtualTextDisplaySupport.java b/packet/src/main/java/dev/twme/textdisplayshape/packet/VirtualTextDisplaySupport.java index 908215a..6f806f5 100644 --- a/packet/src/main/java/dev/twme/textdisplayshape/packet/VirtualTextDisplaySupport.java +++ b/packet/src/main/java/dev/twme/textdisplayshape/packet/VirtualTextDisplaySupport.java @@ -16,7 +16,8 @@ import io.github.twme.virtualentities.metadata.VirtualMetadata; import net.kyori.adventure.text.Component; import org.bukkit.Location; -import org.joml.Matrix4f; + +import dev.twme.textdisplayshape.geometry.DisplayTransform; import java.util.Collection; import java.util.Objects; @@ -74,22 +75,6 @@ static void configureTextDisplay( .set(GeneratedEntityMetadataKeys.Display.VIEW_RANGE, viewRange); } - static void setTransformFromMatrix(VirtualEntity entity, Matrix4f matrix) { - org.joml.Vector3f translation = new org.joml.Vector3f(); - matrix.getTranslation(translation); - org.joml.Vector3f scale = new org.joml.Vector3f(); - matrix.getScale(scale); - org.joml.Quaternionf rotation = new org.joml.Quaternionf(); - matrix.getUnnormalizedRotation(rotation); - setTransform( - entity, - translation, - scale, - rotation, - null - ); - } - static void setTransform( VirtualEntity entity, org.joml.Vector3f translation, @@ -101,9 +86,46 @@ static void setTransform( .set(GeneratedEntityMetadataKeys.Display.TRANSLATION, vector(translation)) .set(GeneratedEntityMetadataKeys.Display.SCALE, vector(scale)) .set(GeneratedEntityMetadataKeys.Display.LEFT_ROTATION, quaternion(leftRotation)); - if (rightRotation != null) { - entity.metadata().set(GeneratedEntityMetadataKeys.Display.RIGHT_ROTATION, quaternion(rightRotation)); - } + entity.metadata().set( + GeneratedEntityMetadataKeys.Display.RIGHT_ROTATION, + quaternion(rightRotation == null ? new org.joml.Quaternionf() : rightRotation) + ); + } + + static void setTransform(VirtualEntity entity, DisplayTransform transform) { + setTransform( + entity, + transform.translation(), + transform.scale(), + transform.leftRotation(), + transform.rightRotation() + ); + } + + static void setBackgroundColor(VirtualEntity entity, int argbColor) { + entity.metadata().set(GeneratedEntityMetadataKeys.TextDisplay.BACKGROUND_COLOR, argbColor); + } + + /** + * Restarts transformation interpolation: the client animates from the + * currently rendered state to the newly sent one over {@code ticks}. + * Text Display background color and opacity follow the same timing. + */ + static void setInterpolation(VirtualEntity entity, int ticks) { + VirtualMetadata metadata = entity.metadata(); + setIfSupported(metadata, LEGACY_INTERPOLATION_START_DELTA_TICKS, 0); + setIfSupported(metadata, LEGACY_INTERPOLATION_DURATION, ticks); + setIfSupported( + metadata, + GeneratedEntityMetadataKeys.Display.TRANSFORMATION_INTERPOLATION_START_DELTA_TICKS, + 0 + ); + setIfSupported(metadata, GeneratedEntityMetadataKeys.Display.TRANSFORMATION_INTERPOLATION_DURATION, ticks); + } + + /** Sets how long position updates take to animate, where the client supports it (1.20.2+). */ + static void setTeleportDuration(VirtualEntity entity, int ticks) { + setIfSupported(entity.metadata(), GeneratedEntityMetadataKeys.Display.POS_ROT_INTERPOLATION_DURATION, ticks); } static void setImmediateInterpolation(VirtualEntity entity) { diff --git a/packet/src/test/java/dev/twme/textdisplayshape/packet/PacketShapeAnimationTest.java b/packet/src/test/java/dev/twme/textdisplayshape/packet/PacketShapeAnimationTest.java new file mode 100644 index 0000000..877c7d4 --- /dev/null +++ b/packet/src/test/java/dev/twme/textdisplayshape/packet/PacketShapeAnimationTest.java @@ -0,0 +1,217 @@ +package dev.twme.textdisplayshape.packet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Location; +import org.joml.Vector3f; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.PacketEventsAPI; +import com.github.retrooper.packetevents.manager.server.ServerManager; +import com.github.retrooper.packetevents.manager.server.ServerVersion; +import com.github.retrooper.packetevents.settings.PacketEventsSettings; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata; + +import dev.twme.textdisplayshape.shape.BoxOutline; +import dev.twme.textdisplayshape.shape.ShapeStyle; +import io.github.twme.virtualentities.VirtualEntities; +import io.github.twme.virtualentities.VirtualEntity; +import io.github.twme.virtualentities.VirtualEntityManager; +import io.github.twme.virtualentities.VirtualViewer; +import io.github.twme.virtualentities.metadata.GeneratedEntityMetadataKeys; + +class PacketShapeAnimationTest { + + private static final Location ORIGIN = new Location(null, 100, 64, 100); + + private VirtualEntityManager manager; + private PacketShapeFactory factory; + + @BeforeAll + static void initializePacketEvents() { + PacketEventsAPI api = mock(PacketEventsAPI.class); + ServerManager serverManager = mock(ServerManager.class); + when(api.getServerManager()).thenReturn(serverManager); + when(api.getSettings()).thenReturn(new PacketEventsSettings()); + when(serverManager.getVersion()).thenReturn(ServerVersion.V_1_21_11); + PacketEvents.setAPI(api); + } + + @AfterAll + static void clearPacketEvents() { + PacketEvents.setAPI(null); + } + + @BeforeEach + void createManager() { + manager = VirtualEntities.create(); + factory = new PacketShapeFactory(manager); + } + + @AfterEach + void closeManager() { + factory.close(); + manager.close(); + } + + @Test + void setPointsReusesEntitiesAndStartsInterpolation() { + PacketLine line = factory.line(ORIGIN, new Vector3f(100, 64, 100), new Vector3f(102, 64, 100), 0.1f) + .interpolationDuration(4) + .build(); + line.spawn(); + VirtualEntity entity = line.getEntities().get(0); + + line.setPoints(new Vector3f(100, 65, 100), new Vector3f(103, 65, 100)); + + assertSame(entity, line.getEntities().get(0), "updates must not respawn entities"); + assertEquals(4, entity.metadata() + .get(GeneratedEntityMetadataKeys.Display.TRANSFORMATION_INTERPOLATION_DURATION).orElseThrow()); + assertEquals(0, entity.metadata() + .get(GeneratedEntityMetadataKeys.Display.TRANSFORMATION_INTERPOLATION_START_DELTA_TICKS) + .orElseThrow()); + var translation = entity.metadata().get(GeneratedEntityMetadataKeys.Display.TRANSLATION).orElseThrow(); + assertEquals(1f, translation.getY(), 0.2f, "translation is relative to the origin"); + assertEquals(new Vector3f(103, 65, 100), line.getEnd()); + } + + @Test + void updatesAreSentAsOneBundlePerViewer() { + PacketTriangle triangle = factory.triangle(ORIGIN, + new Vector3f(100, 64, 100), new Vector3f(101, 64, 100), new Vector3f(100, 65, 100)) + .interpolationDuration(2) + .build(); + triangle.spawn(); + List> sent = new ArrayList<>(); + VirtualViewer viewer = VirtualViewer.of(UUID.randomUUID(), sent::add); + for (VirtualEntity entity : triangle.getEntities()) { + entity.addViewer(viewer); + } + sent.clear(); + + triangle.setPoints(new Vector3f(100, 64, 100), new Vector3f(102, 64, 100), new Vector3f(100, 66, 100)); + + long metadataPackets = sent.stream().filter(WrapperPlayServerEntityMetadata.class::isInstance).count(); + assertEquals(3, metadataPackets, "one metadata packet per triangle piece"); + assertTrue(sent.get(0).getClass().getSimpleName().contains("Bundle"), "bundle opens first: " + sent); + assertTrue(sent.get(sent.size() - 1).getClass().getSimpleName().contains("Bundle"), "bundle closes last"); + } + + @Test + void polylineGrowsAndShrinksWithItsPoints() { + PacketPolyline polyline = factory.polyline(ORIGIN, + List.of(new Vector3f(100, 64, 100), new Vector3f(101, 64, 100)), 0.1f).build(); + polyline.spawn(); + assertEquals(1, polyline.getEntityCount()); + + polyline.setPoints(List.of(new Vector3f(100, 64, 100), new Vector3f(101, 64, 100), + new Vector3f(101, 65, 100), new Vector3f(100, 65, 100))); + assertEquals(3, polyline.getEntityCount()); + assertEquals(3, polyline.getSegmentCount()); + + polyline.setPoints(List.of(new Vector3f(100, 64, 100), new Vector3f(101, 64, 100))); + assertEquals(1, polyline.getEntityCount()); + } + + @Test + void setColorFadesExistingEntities() { + PacketParallelogram face = factory.parallelogram(ORIGIN, + new Vector3f(100, 64, 100), new Vector3f(101, 64, 100), new Vector3f(100, 65, 100)) + .doubleSided(true) + .interpolationDuration(6) + .build(); + face.spawn(); + face.setColor(0x4000FF00); + + assertEquals(0x4000FF00, face.getColor()); + for (VirtualEntity entity : face.getEntities()) { + assertEquals(0x4000FF00, entity.metadata() + .get(GeneratedEntityMetadataKeys.TextDisplay.BACKGROUND_COLOR).orElseThrow()); + assertEquals(6, entity.metadata() + .get(GeneratedEntityMetadataKeys.Display.TRANSFORMATION_INTERPOLATION_DURATION).orElseThrow()); + } + } + + @Test + void translateMovesEntitiesWithTeleportDuration() { + PacketLine line = factory.line(ORIGIN, new Vector3f(100, 64, 100), new Vector3f(101, 64, 100), 0.1f) + .teleportDuration(3) + .build(); + line.spawn(); + var before = line.getEntities().get(0).metadata().get(GeneratedEntityMetadataKeys.Display.TRANSLATION) + .orElseThrow(); + + line.translate(0, 2, 0); + + VirtualEntity entity = line.getEntities().get(0); + assertEquals(66, entity.location().getY(), 1e-9); + assertEquals(3, entity.metadata() + .get(GeneratedEntityMetadataKeys.Display.POS_ROT_INTERPOLATION_DURATION).orElseThrow()); + var after = entity.metadata().get(GeneratedEntityMetadataKeys.Display.TRANSLATION).orElseThrow(); + assertEquals(before.getY(), after.getY(), 1e-6f, "relative geometry is unchanged"); + assertEquals(new Vector3f(100, 66, 100), line.getStart()); + assertEquals(66, line.getOrigin().getY(), 1e-9); + } + + @Test + void rootAnchoredTranslateOnlyMovesTheAnchor() { + PacketLine line = factory.line(ORIGIN, new Vector3f(100, 64, 100), new Vector3f(101, 64, 100), 0.1f) + .rootAnchor(true) + .teleportDuration(2) + .build(); + line.spawn(); + VirtualEntity anchor = line.getRootAnchor(); + assertNotNull(anchor); + + line.translate(1, 0, 0); + + assertEquals(101, anchor.location().getX(), 1e-9); + assertEquals(100, line.getEntities().get(0).location().getX(), 1e-9, "passengers ride the anchor"); + line.remove(); + assertNull(line.getRootAnchor()); + } + + @Test + void boxOutlineUsesTwelveAnimatedLines() { + BoxOutline outline = factory.boxOutline(ORIGIN, new Vector3f(100, 64, 100), new Vector3f(101, 65, 101), + 0.05f, ShapeStyle.DEFAULT.withInterpolationDuration(3)); + outline.spawn(); + assertEquals(12, outline.getEntityCount()); + + outline.setBounds(new Vector3f(100, 64, 100), new Vector3f(102, 66, 102)); + for (var member : outline.getMembers()) { + VirtualEntity entity = ((PacketLine) member).getEntities().get(0); + assertEquals(3, entity.metadata() + .get(GeneratedEntityMetadataKeys.Display.TRANSFORMATION_INTERPOLATION_DURATION).orElseThrow()); + } + outline.remove(); + assertEquals(0, outline.getEntityCount()); + } + + @Test + void invalidUpdatesLeaveTheShapeUnchanged() { + PacketLine line = factory.line(ORIGIN, new Vector3f(100, 64, 100), new Vector3f(101, 64, 100), 0.1f).build(); + line.spawn(); + Vector3f same = new Vector3f(105, 64, 100); + assertThrows(IllegalArgumentException.class, () -> line.setPoints(same, new Vector3f(same))); + assertEquals(new Vector3f(101, 64, 100), line.getEnd()); + assertThrows(IllegalArgumentException.class, () -> line.setTeleportDuration(60)); + } +} diff --git a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java new file mode 100644 index 0000000..d535249 --- /dev/null +++ b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java @@ -0,0 +1,374 @@ +package dev.twme.textdisplayshape.bukkit; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.TextDisplay; +import org.bukkit.util.Transformation; +import org.joml.Vector3f; + +import net.kyori.adventure.text.Component; + +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.shape.Shape; +import dev.twme.textdisplayshape.shape.ShapeBuilder; +import dev.twme.textdisplayshape.shape.ShapeStyle; + +/** + * Shared lifecycle, animation, and movement handling for shapes made of + * server-side Text Display entities. Subclasses only describe + * their geometry as world-space {@link DisplayTransform}s. + * + *

All methods must run on the thread that owns the shape's entities. + * Direct entities are visible to every player, so viewer methods are + * no-ops.

+ */ +public abstract class AbstractBukkitShape implements Shape { + + private Location origin; + private int argbColor; + private final boolean doubleSided; + private final int blockLight; + private final int skyLight; + private final boolean seeThrough; + private final float viewRange; + private int interpolationDuration; + private int teleportDuration; + + private final List displays = new ArrayList<>(); + private boolean spawned; + + protected AbstractBukkitShape(Builder builder) { + this.origin = Objects.requireNonNull(builder.origin, "origin").clone(); + this.argbColor = builder.argbColor; + this.doubleSided = builder.doubleSided; + this.blockLight = builder.blockLight; + this.skyLight = builder.skyLight; + this.seeThrough = builder.seeThrough; + this.viewRange = builder.viewRange; + this.interpolationDuration = builder.interpolationDuration; + this.teleportDuration = builder.teleportDuration; + } + + /** World-space transforms for every entity of the current geometry. */ + protected abstract List computeTransforms(); + + /** Shifts the stored geometry by a world-space offset. */ + protected abstract void offsetGeometry(float dx, float dy, float dz); + + protected final boolean isDoubleSided() { + return doubleSided; + } + + /** The current logical origin of the shape's entities. */ + public Location getOrigin() { + return origin.clone(); + } + + @Override + public void spawn() { + if (spawned) { + return; + } + for (DisplayTransform transform : computeTransforms()) { + spawnDisplay(transform); + } + spawned = true; + } + + private void spawnDisplay(DisplayTransform transform) { + Transformation transformation = toTransformation( + transform.relativeTo(origin.getX(), origin.getY(), origin.getZ())); + TextDisplay display = Objects.requireNonNull(origin.getWorld(), "origin world") + .spawn(origin, TextDisplay.class, entity -> { + entity.text(Component.text(" ")); + entity.setBackgroundColor(Color.fromARGB(argbColor)); + entity.setBrightness(new Display.Brightness(blockLight, skyLight)); + entity.setTransformation(transformation); + entity.setSeeThrough(seeThrough); + entity.setViewRange(viewRange); + entity.setTeleportDuration(teleportDuration); + }); + displays.add(display); + } + + @Override + public void remove() { + for (TextDisplay display : displays) { + if (display.isValid()) { + display.remove(); + } + } + displays.clear(); + spawned = false; + } + + @Override + public boolean isSpawned() { + return spawned; + } + + @Override + public void addViewer(UUID playerUUID) { + } + + @Override + public void removeViewer(UUID playerUUID) { + } + + @Override + public Set getViewerUUIDs() { + return new HashSet<>(); + } + + @Override + public List getEntityUUIDs() { + List uuids = new ArrayList<>(displays.size()); + for (TextDisplay display : displays) { + uuids.add(display.getUniqueId()); + } + return uuids; + } + + @Override + public int getEntityCount() { + return displays.size(); + } + + public List getEntities() { + return new ArrayList<>(displays); + } + + /** + * Sends new geometry. Existing entities are updated in place and animate; + * entities needed by a larger geometry appear immediately, and surplus + * entities are removed. + */ + protected final void applyTransforms(List transforms) { + if (!spawned) { + return; + } + int reused = Math.min(displays.size(), transforms.size()); + for (int index = 0; index < reused; index++) { + TextDisplay display = displays.get(index); + if (!display.isValid()) { + continue; + } + startInterpolation(display); + display.setTransformation(toTransformation( + transforms.get(index).relativeTo(origin.getX(), origin.getY(), origin.getZ()))); + } + while (displays.size() > transforms.size()) { + TextDisplay surplus = displays.remove(displays.size() - 1); + if (surplus.isValid()) { + surplus.remove(); + } + } + for (int index = reused; index < transforms.size(); index++) { + spawnDisplay(transforms.get(index)); + } + } + + private void startInterpolation(TextDisplay display) { + display.setInterpolationDelay(0); + display.setInterpolationDuration(interpolationDuration); + } + + @Override + public void setColor(int argb) { + this.argbColor = argb; + for (TextDisplay display : displays) { + if (display.isValid()) { + startInterpolation(display); + display.setBackgroundColor(Color.fromARGB(argb)); + } + } + } + + @Override + public int getColor() { + return argbColor; + } + + /** Convenience overload of {@link #setColor(int)}. */ + public void setColor(Color color) { + setColor(color.asARGB()); + } + + @Override + public void setInterpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + this.interpolationDuration = ticks; + } + + @Override + public int getInterpolationDuration() { + return interpolationDuration; + } + + @Override + public void setTeleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + this.teleportDuration = ticks; + for (TextDisplay display : displays) { + if (display.isValid()) { + display.setTeleportDuration(ticks); + } + } + } + + @Override + public int getTeleportDuration() { + return teleportDuration; + } + + @Override + public void translate(double dx, double dy, double dz) { + offsetGeometry((float) dx, (float) dy, (float) dz); + origin = origin.clone().add(dx, dy, dz); + for (TextDisplay display : displays) { + if (display.isValid()) { + display.teleport(origin); + } + } + } + + /** + * Rebases the entities without moving the rendered geometry. Rebase with + * a zero teleport duration: Bukkit synchronizes entity data once per + * tick, so an animated teleport would briefly show the entities moving. + */ + @Override + public void teleportOrigin(double x, double y, double z) { + if (!spawned) { + return; + } + Location newOrigin = new Location(origin.getWorld(), x, y, z); + float dx = (float) (x - origin.getX()); + float dy = (float) (y - origin.getY()); + float dz = (float) (z - origin.getZ()); + for (TextDisplay display : displays) { + if (!display.isValid()) { + continue; + } + Transformation current = display.getTransformation(); + Vector3f translation = current.getTranslation(); + display.setInterpolationDuration(0); + display.setTransformation(new Transformation( + new Vector3f(translation.x - dx, translation.y - dy, translation.z - dz), + current.getLeftRotation(), current.getScale(), current.getRightRotation())); + display.teleport(newOrigin); + } + origin = newOrigin.clone(); + } + + private static Transformation toTransformation(DisplayTransform transform) { + return new Transformation( + transform.translation(), + transform.leftRotation(), + transform.scale(), + transform.rightRotation()); + } + + /** + * Shared builder settings. + * + * @param the shape type + * @param the concrete builder type + */ + public abstract static class Builder> + implements ShapeBuilder { + private final Location origin; + private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); + private boolean doubleSided; + private int blockLight = 15; + private int skyLight = 15; + private boolean seeThrough = true; + private float viewRange = 1.0f; + private int interpolationDuration; + private int teleportDuration; + + protected Builder(Location origin) { + this.origin = origin; + } + + @SuppressWarnings("unchecked") + protected final B self() { + return (B) this; + } + + public B color(Color color) { + this.argbColor = color.asARGB(); + return self(); + } + + @Override + public B color(int argb) { + this.argbColor = argb; + return self(); + } + + @Override + public B doubleSided(boolean value) { + this.doubleSided = value; + return self(); + } + + @Override + public B brightness(int blockLight, int skyLight) { + if (blockLight < 0 || blockLight > 15 || skyLight < 0 || skyLight > 15) { + throw new IllegalArgumentException("Light levels must be between 0 and 15"); + } + this.blockLight = blockLight; + this.skyLight = skyLight; + return self(); + } + + @Override + public B seeThrough(boolean value) { + this.seeThrough = value; + return self(); + } + + @Override + public B viewRange(float value) { + this.viewRange = value; + return self(); + } + + @Override + public B interpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + this.interpolationDuration = ticks; + return self(); + } + + @Override + public B teleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + this.teleportDuration = ticks; + return self(); + } + + @Override + public B style(ShapeStyle style) { + ShapeBuilder.super.style(style); + return self(); + } + } +} diff --git a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java index e0abf22..061a624 100644 --- a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java +++ b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java @@ -1,261 +1,81 @@ package dev.twme.textdisplayshape.bukkit; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.joml.Matrix4f; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TextDisplayUtil; -import net.kyori.adventure.text.minimessage.MiniMessage; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.LineShape; -/** - * Line implementation using Bukkit API to directly manipulate TextDisplay - * entities. - */ -public class BukkitLine implements Shape { - - private Location origin; +/** Line implementation using server-side Text Display entities. */ +public class BukkitLine extends AbstractBukkitShape implements LineShape { private final Vector3f p1; private final Vector3f p2; private final float thickness; private final float roll; - private final Color color; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; private BukkitLine(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); this.thickness = builder.thickness; this.roll = builder.roll; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; - } - - @Override - public void spawn() { - if (spawned) - return; - - // Front face: p1 -> p2 - Matrix4f matrix = TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll); - spawnTextDisplay(matrix); - - // Back face: swap p1 and p2, and invert roll - if (doubleSided) { - Matrix4f backMatrix = TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll); - spawnTextDisplay(backMatrix); - } - - spawned = true; - } - - private void spawnTextDisplay(Matrix4f matrix) { - // Adjust transformation matrix: convert absolute coordinates to relative to - // spawn location - Matrix4f adjustedMatrix = new Matrix4f() - .translate( - (float) -origin.getX(), - (float) -origin.getY(), - (float) -origin.getZ()) - .mul(matrix); - - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.text(MiniMessage.miniMessage().deserialize(" ")); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformationMatrix(adjustedMatrix); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); - } - - @Override - public void remove() { - for (TextDisplay display : displays) { - if (display.isValid()) { - display.remove(); - } - } - displays.clear(); - spawned = false; } @Override - public boolean isSpawned() { - return spawned; + protected List computeTransforms() { + return ShapeGeometry.line(p1, p2, thickness, roll, isDoubleSided()); } @Override - public void addViewer(UUID playerUUID) { - // In Bukkit implementation, all players can see the entity + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); } @Override - public void removeViewer(UUID playerUUID) { - // In Bukkit implementation, cannot control individual player visibility + public Vector3f getStart() { + return new Vector3f(p1); } @Override - public Set getViewerUUIDs() { - return new HashSet<>(); + public Vector3f getEnd() { + return new Vector3f(p2); } @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay display : displays) { - uuids.add(display.getUniqueId()); - } - return uuids; + public void setPoints(Vector3f start, Vector3f end) { + List transforms = ShapeGeometry.line(start, end, thickness, roll, isDoubleSided()); + p1.set(start); + p2.set(end); + applyTransforms(transforms); } - /** - * Gets all TextDisplay entities of this shape. - * - * @return list of TextDisplay entities - */ - public List getEntities() { - return new ArrayList<>(displays); - } - - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - - Location newOrigin = new Location(origin.getWorld(), x, y, z); - - float deltaX = (float) (x - origin.getX()); - float deltaY = (float) (y - origin.getY()); - float deltaZ = (float) (z - origin.getZ()); - - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - deltaX, tr.y - deltaY, tr.z - deltaZ), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); - } - - this.origin = newOrigin.clone(); - } - - /** - * Builder class. - */ - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractBukkitShape.Builder { private final Vector3f p1; private final Vector3f p2; private final float thickness; - - private float roll = 0f; - private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); - private boolean doubleSided = false; - private int blockLight = 15; - private int skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + private float roll; public Builder(Location origin, Vector3f p1, Vector3f p2, float thickness) { - this.origin = origin; + super(origin); this.p1 = p1; this.p2 = p2; this.thickness = thickness; } - /** - * Sets the roll angle (rotation around the line axis). - * This allows the line surface to face different directions. - * Default is 0 (facing up when the line is horizontal). - * - * @param roll the roll angle in radians - * @return this builder - */ public Builder roll(float roll) { this.roll = roll; return this; } - /** - * Sets the roll angle in degrees (rotation around the line axis). - * This allows the line surface to face different directions. - * Default is 0 (facing up when the line is horizontal). - * - * @param degrees the roll angle in degrees - * @return this builder - */ public Builder rollDegrees(float degrees) { this.roll = (float) Math.toRadians(degrees); return this; } - /** - * Sets the background color using a Bukkit Color. - * - * @param color the Bukkit color (including ARGB) - * @return this builder - */ - public Builder color(Color color) { - this.argbColor = color.asARGB(); - return this; - } - - @Override - public Builder color(int argb) { - this.argbColor = argb; - return this; - } - - @Override - public Builder doubleSided(boolean doubleSided) { - this.doubleSided = doubleSided; - return this; - } - - @Override - public Builder brightness(int block, int sky) { - this.blockLight = block; - this.skyLight = sky; - return this; - } - - @Override - public Builder seeThrough(boolean seeThrough) { - this.seeThrough = seeThrough; - return this; - } - - @Override - public Builder viewRange(float viewRange) { - this.viewRange = viewRange; - return this; - } - @Override public BukkitLine build() { return new BukkitLine(this); diff --git a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java index 7804d4e..d3a7aa8 100644 --- a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java +++ b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java @@ -1,141 +1,78 @@ package dev.twme.textdisplayshape.bukkit; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.bukkit.util.Transformation; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TRSResult; -import dev.twme.textdisplayshape.util.TextDisplayUtil; -import net.kyori.adventure.text.minimessage.MiniMessage; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.ParallelogramShape; -/** - * Parallelogram implementation using Bukkit API to directly manipulate - * TextDisplay entities. - */ -public class BukkitParallelogram implements Shape { - - private Location origin; +/** Parallelogram implementation using server-side Text Display entities. */ +public class BukkitParallelogram extends AbstractBukkitShape implements ParallelogramShape { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private final Color color; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; private BukkitParallelogram(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; - this.p3 = builder.p3; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); + this.p3 = new Vector3f(builder.p3); } @Override - public void spawn() { - if (spawned) return; - TRSResult frontTRS = TextDisplayUtil.computeParallelogramTRS(p1, p2, p3); - spawnTextDisplay(frontTRS); - if (doubleSided) { - TRSResult backTRS = TextDisplayUtil.computeParallelogramTRS(p1, p3, p2); - spawnTextDisplay(backTRS); - } - spawned = true; + protected List computeTransforms() { + return ShapeGeometry.parallelogram(p1, p2, p3, isDoubleSided()); } - private void spawnTextDisplay(TRSResult trs) { - Vector3f adj = new Vector3f(trs.translation()) - .sub((float) origin.getX(), (float) origin.getY(), (float) origin.getZ()); - Transformation transformation = new Transformation(adj, trs.leftRotation(), trs.scale(), trs.rightRotation()); - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.text(MiniMessage.miniMessage().deserialize(" ")); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformation(transformation); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); + @Override + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); + p3.add(dx, dy, dz); } @Override - public void remove() { - for (TextDisplay d : displays) { if (d.isValid()) d.remove(); } - displays.clear(); - spawned = false; + public Vector3f getPoint1() { + return new Vector3f(p1); } - @Override public boolean isSpawned() { return spawned; } - @Override public void addViewer(UUID playerUUID) { } - @Override public void removeViewer(UUID playerUUID) { } - @Override public Set getViewerUUIDs() { return new HashSet<>(); } - @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay d : displays) uuids.add(d.getUniqueId()); - return uuids; + public Vector3f getPoint2() { + return new Vector3f(p2); } - public List getEntities() { return new ArrayList<>(displays); } + @Override + public Vector3f getPoint3() { + return new Vector3f(p3); + } @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - Location newOrigin = new Location(origin.getWorld(), x, y, z); - float dx = (float)(x - origin.getX()), dy = (float)(y - origin.getY()), dz = (float)(z - origin.getZ()); - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - dx, tr.y - dy, tr.z - dz), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); - } - this.origin = newOrigin.clone(); + public void setPoints(Vector3f point1, Vector3f point2, Vector3f point3) { + List transforms = ShapeGeometry.parallelogram(point1, point2, point3, isDoubleSided()); + p1.set(point1); + p2.set(point2); + p3.set(point3); + applyTransforms(transforms); } - public static class Builder implements ShapeBuilder { - private final Location origin; - private final Vector3f p1, p2, p3; - private int argbColor = Color.fromARGB(150, 100, 50, 150).asARGB(); - private boolean doubleSided = false; - private int blockLight = 15, skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + public static class Builder extends AbstractBukkitShape.Builder { + private final Vector3f p1; + private final Vector3f p2; + private final Vector3f p3; public Builder(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { - this.origin = origin; this.p1 = p1; this.p2 = p2; this.p3 = p3; + super(origin); + this.p1 = p1; + this.p2 = p2; + this.p3 = p3; } - public Builder color(Color color) { this.argbColor = color.asARGB(); return this; } - @Override public Builder color(int argb) { this.argbColor = argb; return this; } - @Override public Builder doubleSided(boolean v) { this.doubleSided = v; return this; } - @Override public Builder brightness(int b, int s) { this.blockLight = b; this.skyLight = s; return this; } - @Override public Builder seeThrough(boolean v) { this.seeThrough = v; return this; } - @Override public Builder viewRange(float v) { this.viewRange = v; return this; } - @Override public BukkitParallelogram build() { return new BukkitParallelogram(this); } + @Override + public BukkitParallelogram build() { + return new BukkitParallelogram(this); + } } } diff --git a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java index 1da0e74..6434950 100644 --- a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java +++ b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java @@ -1,165 +1,101 @@ package dev.twme.textdisplayshape.bukkit; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.joml.Matrix4f; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TextDisplayUtil; -import net.kyori.adventure.text.minimessage.MiniMessage; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.PolylineShape; -/** - * Polyline (connected line segments) implementation using Bukkit API. - * Creates multiple connected line segments from a list of points. - */ -public class BukkitPolyline implements Shape { - - private Location origin; - private final List points; +/** Polyline (connected line segments) using server-side Text Display entities. */ +public class BukkitPolyline extends AbstractBukkitShape implements PolylineShape { + private final List points = new ArrayList<>(); private final float thickness; private final float roll; - private final Color color; - private final boolean doubleSided; private final boolean closed; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; private BukkitPolyline(Builder builder) { - this.origin = builder.origin; - this.points = new ArrayList<>(builder.points); + super(builder); + for (Vector3f point : builder.points) { + points.add(new Vector3f(point)); + } this.thickness = builder.thickness; this.roll = builder.roll; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; this.closed = builder.closed; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; } @Override - public void spawn() { - if (spawned) return; - if (points.size() < 2) { spawned = true; return; } - for (int i = 0; i < points.size() - 1; i++) { - spawnLineSegment(points.get(i), points.get(i + 1)); - } - if (closed && points.size() > 2) { - spawnLineSegment(points.get(points.size() - 1), points.get(0)); - } - spawned = true; - } - - private void spawnLineSegment(Vector3f p1, Vector3f p2) { - Matrix4f matrix = TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll); - spawnTextDisplay(matrix); - if (doubleSided) { - Matrix4f backMatrix = TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll); - spawnTextDisplay(backMatrix); - } - } - - private void spawnTextDisplay(Matrix4f matrix) { - Matrix4f adj = new Matrix4f() - .translate((float) -origin.getX(), (float) -origin.getY(), (float) -origin.getZ()) - .mul(matrix); - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.text(MiniMessage.miniMessage().deserialize(" ")); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformationMatrix(adj); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); + protected List computeTransforms() { + return ShapeGeometry.polyline(points, thickness, roll, closed, isDoubleSided()); } @Override - public void remove() { - for (TextDisplay d : displays) { if (d.isValid()) d.remove(); } - displays.clear(); - spawned = false; + protected void offsetGeometry(float dx, float dy, float dz) { + for (Vector3f point : points) { + point.add(dx, dy, dz); + } } - @Override public boolean isSpawned() { return spawned; } - @Override public void addViewer(UUID playerUUID) { } - @Override public void removeViewer(UUID playerUUID) { } - @Override public Set getViewerUUIDs() { return new HashSet<>(); } - @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay d : displays) uuids.add(d.getUniqueId()); - return uuids; + public List getPoints() { + List copy = new ArrayList<>(points.size()); + for (Vector3f point : points) { + copy.add(new Vector3f(point)); + } + return copy; } - public List getEntities() { return new ArrayList<>(displays); } - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - Location newOrigin = new Location(origin.getWorld(), x, y, z); - float dx = (float)(x - origin.getX()), dy = (float)(y - origin.getY()), dz = (float)(z - origin.getZ()); - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - dx, tr.y - dy, tr.z - dz), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); + public void setPoints(List newPoints) { + List transforms = + ShapeGeometry.polyline(newPoints, thickness, roll, closed, isDoubleSided()); + points.clear(); + for (Vector3f point : newPoints) { + points.add(new Vector3f(point)); } - this.origin = newOrigin.clone(); + applyTransforms(transforms); } public int getSegmentCount() { - if (points.size() < 2) return 0; - return closed ? points.size() : points.size() - 1; + if (points.size() < 2) { + return 0; + } + return closed && points.size() > 2 ? points.size() : points.size() - 1; } - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractBukkitShape.Builder { private final List points; private final float thickness; - private float roll = 0f; - private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); - private boolean doubleSided = false; - private boolean closed = false; - private int blockLight = 15, skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + private float roll; + private boolean closed; public Builder(Location origin, List points, float thickness) { - this.origin = origin; + super(origin); this.points = new ArrayList<>(points); this.thickness = thickness; } - public Builder closed(boolean closed) { this.closed = closed; return this; } - public Builder roll(float roll) { this.roll = roll; return this; } - public Builder rollDegrees(float degrees) { this.roll = (float) Math.toRadians(degrees); return this; } - public Builder color(Color color) { this.argbColor = color.asARGB(); return this; } - @Override public Builder color(int argb) { this.argbColor = argb; return this; } - @Override public Builder doubleSided(boolean v) { this.doubleSided = v; return this; } - @Override public Builder brightness(int b, int s) { this.blockLight = b; this.skyLight = s; return this; } - @Override public Builder seeThrough(boolean v) { this.seeThrough = v; return this; } - @Override public Builder viewRange(float v) { this.viewRange = v; return this; } - @Override public BukkitPolyline build() { return new BukkitPolyline(this); } + public Builder closed(boolean value) { + this.closed = value; + return this; + } + + public Builder roll(float roll) { + this.roll = roll; + return this; + } + + public Builder rollDegrees(float degrees) { + this.roll = (float) Math.toRadians(degrees); + return this; + } + + @Override + public BukkitPolyline build() { + return new BukkitPolyline(this); + } } } diff --git a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java index 9b48931..5897ad7 100644 --- a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java +++ b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java @@ -3,6 +3,10 @@ import org.bukkit.Location; import org.joml.Vector3f; +import dev.twme.textdisplayshape.shape.BoxFaces; +import dev.twme.textdisplayshape.shape.BoxOutline; +import dev.twme.textdisplayshape.shape.ShapeStyle; + import java.util.List; /** @@ -64,4 +68,41 @@ public BukkitPolyline.Builder polyline(Location origin, List points, f public BukkitParallelogram.Builder parallelogram(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { return new BukkitParallelogram.Builder(origin, p1, p2, p3); } + + /** + * Creates the twelve edges of an axis-aligned box, sharing one style. + * Call {@link BoxOutline#setBounds} to resize or move it in place. + * + * @param origin the spawn location + * @param min one corner of the box (world coordinates) + * @param max the opposite corner (world coordinates) + * @param thickness the edge thickness + * @param style appearance and animation settings for every edge + * @return the unspawned outline + */ + public BoxOutline boxOutline(Location origin, Vector3f min, Vector3f max, float thickness, ShapeStyle style) { + BoxOutline outline = new BoxOutline(min, max, + edge -> line(origin, edge.from(), edge.to(), thickness).style(style).build()); + outline.setInterpolationDuration(style.interpolationDuration()); + outline.setTeleportDuration(style.teleportDuration()); + return outline; + } + + /** + * Creates the six outward-facing faces of an axis-aligned box. + * Call {@link BoxFaces#setBounds} to resize or move it in place. + * + * @param origin the spawn location + * @param min one corner of the box (world coordinates) + * @param max the opposite corner (world coordinates) + * @param style appearance and animation settings for every face + * @return the unspawned faces + */ + public BoxFaces boxFaces(Location origin, Vector3f min, Vector3f max, ShapeStyle style) { + BoxFaces faces = new BoxFaces(min, max, + face -> parallelogram(origin, face.corner(), face.first(), face.second()).style(style).build()); + faces.setInterpolationDuration(style.interpolationDuration()); + faces.setTeleportDuration(style.teleportDuration()); + return faces; + } } diff --git a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java index 531417a..8e03f87 100644 --- a/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java +++ b/paper/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java @@ -1,239 +1,75 @@ package dev.twme.textdisplayshape.bukkit; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.bukkit.util.Transformation; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TRSResult; -import dev.twme.textdisplayshape.util.TextDisplayUtil; -import net.kyori.adventure.text.minimessage.MiniMessage; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.TriangleShape; -/** - * Triangle implementation using Bukkit API to directly manipulate TextDisplay - * entities. - */ -public class BukkitTriangle implements Shape { - - private Location origin; +/** Triangle implementation using server-side Text Display entities. */ +public class BukkitTriangle extends AbstractBukkitShape implements TriangleShape { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private final Color color; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; private BukkitTriangle(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; - this.p3 = builder.p3; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; - } - - @Override - public void spawn() { - if (spawned) - return; - - // Front face: p1, p2, p3 — use analytical TRS for precision - for (TRSResult trs : TextDisplayUtil.computeTriangleTRS(p1, p2, p3)) { - spawnTextDisplay(trs); - } - - // Back face: swap p2 and p3 - if (doubleSided) { - for (TRSResult trs : TextDisplayUtil.computeTriangleTRS(p1, p3, p2)) { - spawnTextDisplay(trs); - } - } - - spawned = true; - } - - private void spawnTextDisplay(TRSResult trs) { - // Adjust translation: convert from absolute world coordinates to relative to spawn location - Vector3f adjustedTranslation = new Vector3f(trs.translation()) - .sub((float) origin.getX(), (float) origin.getY(), (float) origin.getZ()); - - Transformation transformation = new Transformation( - adjustedTranslation, - trs.leftRotation(), - trs.scale(), - trs.rightRotation()); - - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.text(MiniMessage.miniMessage().deserialize(" ")); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformation(transformation); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); + this.p3 = new Vector3f(builder.p3); } @Override - public void remove() { - for (TextDisplay display : displays) { - if (display.isValid()) { - display.remove(); - } - } - displays.clear(); - spawned = false; + protected List computeTransforms() { + return ShapeGeometry.triangle(p1, p2, p3, isDoubleSided()); } @Override - public boolean isSpawned() { - return spawned; + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); + p3.add(dx, dy, dz); } @Override - public void addViewer(UUID playerUUID) { - // In Bukkit implementation, all players can see the entity - // This method is reserved for packet mode + public Vector3f getPoint1() { + return new Vector3f(p1); } @Override - public void removeViewer(UUID playerUUID) { - // In Bukkit implementation, cannot control individual player visibility - // This method is reserved for packet mode + public Vector3f getPoint2() { + return new Vector3f(p2); } @Override - public Set getViewerUUIDs() { - // Returns empty set since Bukkit mode is visible to all players - return new HashSet<>(); + public Vector3f getPoint3() { + return new Vector3f(p3); } @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay display : displays) { - uuids.add(display.getUniqueId()); - } - return uuids; - } - - /** - * Gets all TextDisplay entities of this shape. - * - * @return list of TextDisplay entities - */ - public List getEntities() { - return new ArrayList<>(displays); - } - - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - - Location newOrigin = new Location(origin.getWorld(), x, y, z); - - float deltaX = (float) (x - origin.getX()); - float deltaY = (float) (y - origin.getY()); - float deltaZ = (float) (z - origin.getZ()); - - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - deltaX, tr.y - deltaY, tr.z - deltaZ), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); - } - - this.origin = newOrigin.clone(); + public void setPoints(Vector3f point1, Vector3f point2, Vector3f point3) { + List transforms = ShapeGeometry.triangle(point1, point2, point3, isDoubleSided()); + p1.set(point1); + p2.set(point2); + p3.set(point3); + applyTransforms(transforms); } - /** - * Builder class. - */ - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractBukkitShape.Builder { private final Vector3f p1; private final Vector3f p2; private final Vector3f p3; - private int argbColor = Color.fromARGB(150, 50, 100, 100).asARGB(); - private boolean doubleSided = false; - private int blockLight = 15; - private int skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; - public Builder(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { - this.origin = origin; + super(origin); this.p1 = p1; this.p2 = p2; this.p3 = p3; } - /** - * Sets the background color using a Bukkit Color. - * - * @param color the Bukkit color (including ARGB) - * @return this builder - */ - public Builder color(Color color) { - this.argbColor = color.asARGB(); - return this; - } - - @Override - public Builder color(int argb) { - this.argbColor = argb; - return this; - } - - @Override - public Builder doubleSided(boolean doubleSided) { - this.doubleSided = doubleSided; - return this; - } - - @Override - public Builder brightness(int block, int sky) { - this.blockLight = block; - this.skyLight = sky; - return this; - } - - @Override - public Builder seeThrough(boolean seeThrough) { - this.seeThrough = seeThrough; - return this; - } - - @Override - public Builder viewRange(float viewRange) { - this.viewRange = viewRange; - return this; - } - @Override public BukkitTriangle build() { return new BukkitTriangle(this); diff --git a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java new file mode 100644 index 0000000..064c9d1 --- /dev/null +++ b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/AbstractBukkitShape.java @@ -0,0 +1,372 @@ +package dev.twme.textdisplayshape.bukkit; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.TextDisplay; +import org.bukkit.util.Transformation; +import org.joml.Vector3f; + +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.shape.Shape; +import dev.twme.textdisplayshape.shape.ShapeBuilder; +import dev.twme.textdisplayshape.shape.ShapeStyle; + +/** + * Shared lifecycle, animation, and movement handling for shapes made of + * server-side Text Display entities (Spigot-compatible, no Adventure API). Subclasses only describe + * their geometry as world-space {@link DisplayTransform}s. + * + *

All methods must run on the thread that owns the shape's entities. + * Direct entities are visible to every player, so viewer methods are + * no-ops.

+ */ +public abstract class AbstractBukkitShape implements Shape { + + private Location origin; + private int argbColor; + private final boolean doubleSided; + private final int blockLight; + private final int skyLight; + private final boolean seeThrough; + private final float viewRange; + private int interpolationDuration; + private int teleportDuration; + + private final List displays = new ArrayList<>(); + private boolean spawned; + + protected AbstractBukkitShape(Builder builder) { + this.origin = Objects.requireNonNull(builder.origin, "origin").clone(); + this.argbColor = builder.argbColor; + this.doubleSided = builder.doubleSided; + this.blockLight = builder.blockLight; + this.skyLight = builder.skyLight; + this.seeThrough = builder.seeThrough; + this.viewRange = builder.viewRange; + this.interpolationDuration = builder.interpolationDuration; + this.teleportDuration = builder.teleportDuration; + } + + /** World-space transforms for every entity of the current geometry. */ + protected abstract List computeTransforms(); + + /** Shifts the stored geometry by a world-space offset. */ + protected abstract void offsetGeometry(float dx, float dy, float dz); + + protected final boolean isDoubleSided() { + return doubleSided; + } + + /** The current logical origin of the shape's entities. */ + public Location getOrigin() { + return origin.clone(); + } + + @Override + public void spawn() { + if (spawned) { + return; + } + for (DisplayTransform transform : computeTransforms()) { + spawnDisplay(transform); + } + spawned = true; + } + + private void spawnDisplay(DisplayTransform transform) { + Transformation transformation = toTransformation( + transform.relativeTo(origin.getX(), origin.getY(), origin.getZ())); + TextDisplay display = Objects.requireNonNull(origin.getWorld(), "origin world") + .spawn(origin, TextDisplay.class, entity -> { + entity.setText(" "); + entity.setBackgroundColor(Color.fromARGB(argbColor)); + entity.setBrightness(new Display.Brightness(blockLight, skyLight)); + entity.setTransformation(transformation); + entity.setSeeThrough(seeThrough); + entity.setViewRange(viewRange); + entity.setTeleportDuration(teleportDuration); + }); + displays.add(display); + } + + @Override + public void remove() { + for (TextDisplay display : displays) { + if (display.isValid()) { + display.remove(); + } + } + displays.clear(); + spawned = false; + } + + @Override + public boolean isSpawned() { + return spawned; + } + + @Override + public void addViewer(UUID playerUUID) { + } + + @Override + public void removeViewer(UUID playerUUID) { + } + + @Override + public Set getViewerUUIDs() { + return new HashSet<>(); + } + + @Override + public List getEntityUUIDs() { + List uuids = new ArrayList<>(displays.size()); + for (TextDisplay display : displays) { + uuids.add(display.getUniqueId()); + } + return uuids; + } + + @Override + public int getEntityCount() { + return displays.size(); + } + + public List getEntities() { + return new ArrayList<>(displays); + } + + /** + * Sends new geometry. Existing entities are updated in place and animate; + * entities needed by a larger geometry appear immediately, and surplus + * entities are removed. + */ + protected final void applyTransforms(List transforms) { + if (!spawned) { + return; + } + int reused = Math.min(displays.size(), transforms.size()); + for (int index = 0; index < reused; index++) { + TextDisplay display = displays.get(index); + if (!display.isValid()) { + continue; + } + startInterpolation(display); + display.setTransformation(toTransformation( + transforms.get(index).relativeTo(origin.getX(), origin.getY(), origin.getZ()))); + } + while (displays.size() > transforms.size()) { + TextDisplay surplus = displays.remove(displays.size() - 1); + if (surplus.isValid()) { + surplus.remove(); + } + } + for (int index = reused; index < transforms.size(); index++) { + spawnDisplay(transforms.get(index)); + } + } + + private void startInterpolation(TextDisplay display) { + display.setInterpolationDelay(0); + display.setInterpolationDuration(interpolationDuration); + } + + @Override + public void setColor(int argb) { + this.argbColor = argb; + for (TextDisplay display : displays) { + if (display.isValid()) { + startInterpolation(display); + display.setBackgroundColor(Color.fromARGB(argb)); + } + } + } + + @Override + public int getColor() { + return argbColor; + } + + /** Convenience overload of {@link #setColor(int)}. */ + public void setColor(Color color) { + setColor(color.asARGB()); + } + + @Override + public void setInterpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + this.interpolationDuration = ticks; + } + + @Override + public int getInterpolationDuration() { + return interpolationDuration; + } + + @Override + public void setTeleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + this.teleportDuration = ticks; + for (TextDisplay display : displays) { + if (display.isValid()) { + display.setTeleportDuration(ticks); + } + } + } + + @Override + public int getTeleportDuration() { + return teleportDuration; + } + + @Override + public void translate(double dx, double dy, double dz) { + offsetGeometry((float) dx, (float) dy, (float) dz); + origin = origin.clone().add(dx, dy, dz); + for (TextDisplay display : displays) { + if (display.isValid()) { + display.teleport(origin); + } + } + } + + /** + * Rebases the entities without moving the rendered geometry. Rebase with + * a zero teleport duration: Bukkit synchronizes entity data once per + * tick, so an animated teleport would briefly show the entities moving. + */ + @Override + public void teleportOrigin(double x, double y, double z) { + if (!spawned) { + return; + } + Location newOrigin = new Location(origin.getWorld(), x, y, z); + float dx = (float) (x - origin.getX()); + float dy = (float) (y - origin.getY()); + float dz = (float) (z - origin.getZ()); + for (TextDisplay display : displays) { + if (!display.isValid()) { + continue; + } + Transformation current = display.getTransformation(); + Vector3f translation = current.getTranslation(); + display.setInterpolationDuration(0); + display.setTransformation(new Transformation( + new Vector3f(translation.x - dx, translation.y - dy, translation.z - dz), + current.getLeftRotation(), current.getScale(), current.getRightRotation())); + display.teleport(newOrigin); + } + origin = newOrigin.clone(); + } + + private static Transformation toTransformation(DisplayTransform transform) { + return new Transformation( + transform.translation(), + transform.leftRotation(), + transform.scale(), + transform.rightRotation()); + } + + /** + * Shared builder settings. + * + * @param the shape type + * @param the concrete builder type + */ + public abstract static class Builder> + implements ShapeBuilder { + private final Location origin; + private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); + private boolean doubleSided; + private int blockLight = 15; + private int skyLight = 15; + private boolean seeThrough = true; + private float viewRange = 1.0f; + private int interpolationDuration; + private int teleportDuration; + + protected Builder(Location origin) { + this.origin = origin; + } + + @SuppressWarnings("unchecked") + protected final B self() { + return (B) this; + } + + public B color(Color color) { + this.argbColor = color.asARGB(); + return self(); + } + + @Override + public B color(int argb) { + this.argbColor = argb; + return self(); + } + + @Override + public B doubleSided(boolean value) { + this.doubleSided = value; + return self(); + } + + @Override + public B brightness(int blockLight, int skyLight) { + if (blockLight < 0 || blockLight > 15 || skyLight < 0 || skyLight > 15) { + throw new IllegalArgumentException("Light levels must be between 0 and 15"); + } + this.blockLight = blockLight; + this.skyLight = skyLight; + return self(); + } + + @Override + public B seeThrough(boolean value) { + this.seeThrough = value; + return self(); + } + + @Override + public B viewRange(float value) { + this.viewRange = value; + return self(); + } + + @Override + public B interpolationDuration(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("ticks must not be negative"); + } + this.interpolationDuration = ticks; + return self(); + } + + @Override + public B teleportDuration(int ticks) { + if (ticks < 0 || ticks > MAX_TELEPORT_DURATION) { + throw new IllegalArgumentException("ticks must be between 0 and " + MAX_TELEPORT_DURATION); + } + this.teleportDuration = ticks; + return self(); + } + + @Override + public B style(ShapeStyle style) { + ShapeBuilder.super.style(style); + return self(); + } + } +} diff --git a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java index 9485488..35aa0fc 100644 --- a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java +++ b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitLine.java @@ -1,144 +1,84 @@ package dev.twme.textdisplayshape.bukkit; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.joml.Matrix4f; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TextDisplayUtil; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.LineShape; -/** - * Line implementation using Bukkit API (Spigot-compatible). - * Uses {@code setText()} instead of Paper's Adventure API. - */ -public class BukkitLine implements Shape { - - private Location origin; +/** Line implementation using server-side Text Display entities (Spigot-compatible, no Adventure API). */ +public class BukkitLine extends AbstractBukkitShape implements LineShape { private final Vector3f p1; private final Vector3f p2; private final float thickness; private final float roll; - private final Color color; - private final boolean doubleSided; - private final int blockLight; - private final int skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; private BukkitLine(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; - this.p2 = builder.p2; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); this.thickness = builder.thickness; this.roll = builder.roll; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; - this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; } @Override - public void spawn() { - if (spawned) return; - Matrix4f matrix = TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll); - spawnTextDisplay(matrix); - if (doubleSided) { - Matrix4f backMatrix = TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll); - spawnTextDisplay(backMatrix); - } - spawned = true; + protected List computeTransforms() { + return ShapeGeometry.line(p1, p2, thickness, roll, isDoubleSided()); } - private void spawnTextDisplay(Matrix4f matrix) { - Matrix4f adjustedMatrix = new Matrix4f() - .translate((float) -origin.getX(), (float) -origin.getY(), (float) -origin.getZ()) - .mul(matrix); - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.setText(" "); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformationMatrix(adjustedMatrix); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); + @Override + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); } @Override - public void remove() { - for (TextDisplay d : displays) { if (d.isValid()) d.remove(); } - displays.clear(); - spawned = false; + public Vector3f getStart() { + return new Vector3f(p1); } - @Override public boolean isSpawned() { return spawned; } - @Override public void addViewer(UUID playerUUID) { } - @Override public void removeViewer(UUID playerUUID) { } - @Override public Set getViewerUUIDs() { return new HashSet<>(); } - @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay d : displays) uuids.add(d.getUniqueId()); - return uuids; + public Vector3f getEnd() { + return new Vector3f(p2); } - public List getEntities() { return new ArrayList<>(displays); } - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - Location newOrigin = new Location(origin.getWorld(), x, y, z); - float dx = (float)(x - origin.getX()), dy = (float)(y - origin.getY()), dz = (float)(z - origin.getZ()); - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - dx, tr.y - dy, tr.z - dz), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); - } - this.origin = newOrigin.clone(); + public void setPoints(Vector3f start, Vector3f end) { + List transforms = ShapeGeometry.line(start, end, thickness, roll, isDoubleSided()); + p1.set(start); + p2.set(end); + applyTransforms(transforms); } - public static class Builder implements ShapeBuilder { - private final Location origin; - private final Vector3f p1, p2; + public static class Builder extends AbstractBukkitShape.Builder { + private final Vector3f p1; + private final Vector3f p2; private final float thickness; - private float roll = 0f; - private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); - private boolean doubleSided = false; - private int blockLight = 15, skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + private float roll; public Builder(Location origin, Vector3f p1, Vector3f p2, float thickness) { - this.origin = origin; this.p1 = p1; this.p2 = p2; this.thickness = thickness; + super(origin); + this.p1 = p1; + this.p2 = p2; + this.thickness = thickness; + } + + public Builder roll(float roll) { + this.roll = roll; + return this; + } + + public Builder rollDegrees(float degrees) { + this.roll = (float) Math.toRadians(degrees); + return this; + } + + @Override + public BukkitLine build() { + return new BukkitLine(this); } - public Builder roll(float roll) { this.roll = roll; return this; } - public Builder rollDegrees(float degrees) { this.roll = (float) Math.toRadians(degrees); return this; } - public Builder color(Color color) { this.argbColor = color.asARGB(); return this; } - @Override public Builder color(int argb) { this.argbColor = argb; return this; } - @Override public Builder doubleSided(boolean v) { this.doubleSided = v; return this; } - @Override public Builder brightness(int b, int s) { this.blockLight = b; this.skyLight = s; return this; } - @Override public Builder seeThrough(boolean v) { this.seeThrough = v; return this; } - @Override public Builder viewRange(float v) { this.viewRange = v; return this; } - @Override public BukkitLine build() { return new BukkitLine(this); } } } diff --git a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java index b439eea..1eb458e 100644 --- a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java +++ b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitParallelogram.java @@ -1,128 +1,78 @@ package dev.twme.textdisplayshape.bukkit; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.bukkit.util.Transformation; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TRSResult; -import dev.twme.textdisplayshape.util.TextDisplayUtil; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.ParallelogramShape; -/** - * Parallelogram implementation using Bukkit API (Spigot-compatible). - * Uses {@code setText()} instead of Paper's Adventure API. - */ -public class BukkitParallelogram implements Shape { - - private Location origin; - private final Vector3f p1, p2, p3; - private final Color color; - private final boolean doubleSided; - private final int blockLight, skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; +/** Parallelogram implementation using server-side Text Display entities (Spigot-compatible, no Adventure API). */ +public class BukkitParallelogram extends AbstractBukkitShape implements ParallelogramShape { + private final Vector3f p1; + private final Vector3f p2; + private final Vector3f p3; private BukkitParallelogram(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; this.p2 = builder.p2; this.p3 = builder.p3; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); + this.p3 = new Vector3f(builder.p3); } @Override - public void spawn() { - if (spawned) return; - spawnTextDisplay(TextDisplayUtil.computeParallelogramTRS(p1, p2, p3)); - if (doubleSided) { spawnTextDisplay(TextDisplayUtil.computeParallelogramTRS(p1, p3, p2)); } - spawned = true; + protected List computeTransforms() { + return ShapeGeometry.parallelogram(p1, p2, p3, isDoubleSided()); } - private void spawnTextDisplay(TRSResult trs) { - Vector3f adj = new Vector3f(trs.translation()).sub((float) origin.getX(), (float) origin.getY(), (float) origin.getZ()); - Transformation transformation = new Transformation(adj, trs.leftRotation(), trs.scale(), trs.rightRotation()); - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.setText(" "); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformation(transformation); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); + @Override + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); + p3.add(dx, dy, dz); } @Override - public void remove() { - for (TextDisplay d : displays) { if (d.isValid()) d.remove(); } - displays.clear(); - spawned = false; + public Vector3f getPoint1() { + return new Vector3f(p1); } - @Override public boolean isSpawned() { return spawned; } - @Override public void addViewer(UUID playerUUID) { } - @Override public void removeViewer(UUID playerUUID) { } - @Override public Set getViewerUUIDs() { return new HashSet<>(); } - @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay d : displays) uuids.add(d.getUniqueId()); - return uuids; + public Vector3f getPoint2() { + return new Vector3f(p2); } - public List getEntities() { return new ArrayList<>(displays); } + @Override + public Vector3f getPoint3() { + return new Vector3f(p3); + } @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - Location newOrigin = new Location(origin.getWorld(), x, y, z); - float dx = (float)(x - origin.getX()), dy = (float)(y - origin.getY()), dz = (float)(z - origin.getZ()); - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - dx, tr.y - dy, tr.z - dz), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); - } - this.origin = newOrigin.clone(); + public void setPoints(Vector3f point1, Vector3f point2, Vector3f point3) { + List transforms = ShapeGeometry.parallelogram(point1, point2, point3, isDoubleSided()); + p1.set(point1); + p2.set(point2); + p3.set(point3); + applyTransforms(transforms); } - public static class Builder implements ShapeBuilder { - private final Location origin; - private final Vector3f p1, p2, p3; - private int argbColor = Color.fromARGB(150, 100, 50, 150).asARGB(); - private boolean doubleSided = false; - private int blockLight = 15, skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + public static class Builder extends AbstractBukkitShape.Builder { + private final Vector3f p1; + private final Vector3f p2; + private final Vector3f p3; public Builder(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { - this.origin = origin; this.p1 = p1; this.p2 = p2; this.p3 = p3; + super(origin); + this.p1 = p1; + this.p2 = p2; + this.p3 = p3; + } + + @Override + public BukkitParallelogram build() { + return new BukkitParallelogram(this); } - public Builder color(Color color) { this.argbColor = color.asARGB(); return this; } - @Override public Builder color(int argb) { this.argbColor = argb; return this; } - @Override public Builder doubleSided(boolean v) { this.doubleSided = v; return this; } - @Override public Builder brightness(int b, int s) { this.blockLight = b; this.skyLight = s; return this; } - @Override public Builder seeThrough(boolean v) { this.seeThrough = v; return this; } - @Override public Builder viewRange(float v) { this.viewRange = v; return this; } - @Override public BukkitParallelogram build() { return new BukkitParallelogram(this); } } } diff --git a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java index ce983a8..368d01b 100644 --- a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java +++ b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitPolyline.java @@ -1,155 +1,101 @@ package dev.twme.textdisplayshape.bukkit; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.joml.Matrix4f; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TextDisplayUtil; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.PolylineShape; -/** - * Polyline (connected line segments) implementation using Bukkit API (Spigot-compatible). - * Uses {@code setText()} instead of Paper's Adventure API. - */ -public class BukkitPolyline implements Shape { - - private Location origin; - private final List points; +/** Polyline (connected line segments) using server-side Text Display entities (Spigot-compatible, no Adventure API). */ +public class BukkitPolyline extends AbstractBukkitShape implements PolylineShape { + private final List points = new ArrayList<>(); private final float thickness; private final float roll; - private final Color color; - private final boolean doubleSided; private final boolean closed; - private final int blockLight, skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; private BukkitPolyline(Builder builder) { - this.origin = builder.origin; - this.points = new ArrayList<>(builder.points); + super(builder); + for (Vector3f point : builder.points) { + points.add(new Vector3f(point)); + } this.thickness = builder.thickness; this.roll = builder.roll; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; this.closed = builder.closed; - this.blockLight = builder.blockLight; this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; } @Override - public void spawn() { - if (spawned) return; - if (points.size() < 2) { spawned = true; return; } - for (int i = 0; i < points.size() - 1; i++) { spawnLineSegment(points.get(i), points.get(i + 1)); } - if (closed && points.size() > 2) { spawnLineSegment(points.get(points.size() - 1), points.get(0)); } - spawned = true; + protected List computeTransforms() { + return ShapeGeometry.polyline(points, thickness, roll, closed, isDoubleSided()); } - private void spawnLineSegment(Vector3f p1, Vector3f p2) { - Matrix4f matrix = TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll); - spawnTextDisplay(matrix); - if (doubleSided) { - Matrix4f backMatrix = TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll); - spawnTextDisplay(backMatrix); + @Override + protected void offsetGeometry(float dx, float dy, float dz) { + for (Vector3f point : points) { + point.add(dx, dy, dz); } } - private void spawnTextDisplay(Matrix4f matrix) { - Matrix4f adj = new Matrix4f() - .translate((float) -origin.getX(), (float) -origin.getY(), (float) -origin.getZ()) - .mul(matrix); - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.setText(" "); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformationMatrix(adj); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); - } - @Override - public void remove() { - for (TextDisplay d : displays) { if (d.isValid()) d.remove(); } - displays.clear(); - spawned = false; + public List getPoints() { + List copy = new ArrayList<>(points.size()); + for (Vector3f point : points) { + copy.add(new Vector3f(point)); + } + return copy; } - @Override public boolean isSpawned() { return spawned; } - @Override public void addViewer(UUID playerUUID) { } - @Override public void removeViewer(UUID playerUUID) { } - @Override public Set getViewerUUIDs() { return new HashSet<>(); } - @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay d : displays) uuids.add(d.getUniqueId()); - return uuids; - } - - public List getEntities() { return new ArrayList<>(displays); } - - @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - Location newOrigin = new Location(origin.getWorld(), x, y, z); - float dx = (float)(x - origin.getX()), dy = (float)(y - origin.getY()), dz = (float)(z - origin.getZ()); - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - dx, tr.y - dy, tr.z - dz), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); + public void setPoints(List newPoints) { + List transforms = + ShapeGeometry.polyline(newPoints, thickness, roll, closed, isDoubleSided()); + points.clear(); + for (Vector3f point : newPoints) { + points.add(new Vector3f(point)); } - this.origin = newOrigin.clone(); + applyTransforms(transforms); } public int getSegmentCount() { - if (points.size() < 2) return 0; - return closed ? points.size() : points.size() - 1; + if (points.size() < 2) { + return 0; + } + return closed && points.size() > 2 ? points.size() : points.size() - 1; } - public static class Builder implements ShapeBuilder { - private final Location origin; + public static class Builder extends AbstractBukkitShape.Builder { private final List points; private final float thickness; - private float roll = 0f; - private int argbColor = Color.fromARGB(200, 255, 100, 100).asARGB(); - private boolean doubleSided = false; - private boolean closed = false; - private int blockLight = 15, skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + private float roll; + private boolean closed; public Builder(Location origin, List points, float thickness) { - this.origin = origin; this.points = new ArrayList<>(points); this.thickness = thickness; + super(origin); + this.points = new ArrayList<>(points); + this.thickness = thickness; + } + + public Builder closed(boolean value) { + this.closed = value; + return this; + } + + public Builder roll(float roll) { + this.roll = roll; + return this; + } + + public Builder rollDegrees(float degrees) { + this.roll = (float) Math.toRadians(degrees); + return this; + } + + @Override + public BukkitPolyline build() { + return new BukkitPolyline(this); } - public Builder closed(boolean v) { this.closed = v; return this; } - public Builder roll(float v) { this.roll = v; return this; } - public Builder rollDegrees(float degrees) { this.roll = (float) Math.toRadians(degrees); return this; } - public Builder color(Color color) { this.argbColor = color.asARGB(); return this; } - @Override public Builder color(int argb) { this.argbColor = argb; return this; } - @Override public Builder doubleSided(boolean v) { this.doubleSided = v; return this; } - @Override public Builder brightness(int b, int s) { this.blockLight = b; this.skyLight = s; return this; } - @Override public Builder seeThrough(boolean v) { this.seeThrough = v; return this; } - @Override public Builder viewRange(float v) { this.viewRange = v; return this; } - @Override public BukkitPolyline build() { return new BukkitPolyline(this); } } } diff --git a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java index 4ffac57..1b9cb81 100644 --- a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java +++ b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitShapeFactory.java @@ -3,6 +3,10 @@ import org.bukkit.Location; import org.joml.Vector3f; +import dev.twme.textdisplayshape.shape.BoxFaces; +import dev.twme.textdisplayshape.shape.BoxOutline; +import dev.twme.textdisplayshape.shape.ShapeStyle; + import java.util.List; /** @@ -65,4 +69,41 @@ public BukkitPolyline.Builder polyline(Location origin, List points, f public BukkitParallelogram.Builder parallelogram(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { return new BukkitParallelogram.Builder(origin, p1, p2, p3); } + + /** + * Creates the twelve edges of an axis-aligned box, sharing one style. + * Call {@link BoxOutline#setBounds} to resize or move it in place. + * + * @param origin the spawn location + * @param min one corner of the box (world coordinates) + * @param max the opposite corner (world coordinates) + * @param thickness the edge thickness + * @param style appearance and animation settings for every edge + * @return the unspawned outline + */ + public BoxOutline boxOutline(Location origin, Vector3f min, Vector3f max, float thickness, ShapeStyle style) { + BoxOutline outline = new BoxOutline(min, max, + edge -> line(origin, edge.from(), edge.to(), thickness).style(style).build()); + outline.setInterpolationDuration(style.interpolationDuration()); + outline.setTeleportDuration(style.teleportDuration()); + return outline; + } + + /** + * Creates the six outward-facing faces of an axis-aligned box. + * Call {@link BoxFaces#setBounds} to resize or move it in place. + * + * @param origin the spawn location + * @param min one corner of the box (world coordinates) + * @param max the opposite corner (world coordinates) + * @param style appearance and animation settings for every face + * @return the unspawned faces + */ + public BoxFaces boxFaces(Location origin, Vector3f min, Vector3f max, ShapeStyle style) { + BoxFaces faces = new BoxFaces(min, max, + face -> parallelogram(origin, face.corner(), face.first(), face.second()).style(style).build()); + faces.setInterpolationDuration(style.interpolationDuration()); + faces.setTeleportDuration(style.teleportDuration()); + return faces; + } } diff --git a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java index 54ec85d..735133f 100644 --- a/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java +++ b/spigot/src/main/java/dev/twme/textdisplayshape/bukkit/BukkitTriangle.java @@ -1,128 +1,78 @@ package dev.twme.textdisplayshape.bukkit; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.bukkit.Color; import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.TextDisplay; -import org.bukkit.util.Transformation; import org.joml.Vector3f; -import dev.twme.textdisplayshape.shape.Shape; -import dev.twme.textdisplayshape.shape.ShapeBuilder; -import dev.twme.textdisplayshape.util.TRSResult; -import dev.twme.textdisplayshape.util.TextDisplayUtil; +import dev.twme.textdisplayshape.geometry.DisplayTransform; +import dev.twme.textdisplayshape.geometry.ShapeGeometry; +import dev.twme.textdisplayshape.shape.TriangleShape; -/** - * Triangle implementation using Bukkit API (Spigot-compatible). - * Uses {@code setText()} instead of Paper's Adventure API. - */ -public class BukkitTriangle implements Shape { - - private Location origin; - private final Vector3f p1, p2, p3; - private final Color color; - private final boolean doubleSided; - private final int blockLight, skyLight; - private final boolean seeThrough; - private final float viewRange; - - private final List displays = new ArrayList<>(); - private boolean spawned = false; +/** Triangle implementation using server-side Text Display entities (Spigot-compatible, no Adventure API). */ +public class BukkitTriangle extends AbstractBukkitShape implements TriangleShape { + private final Vector3f p1; + private final Vector3f p2; + private final Vector3f p3; private BukkitTriangle(Builder builder) { - this.origin = builder.origin; - this.p1 = builder.p1; this.p2 = builder.p2; this.p3 = builder.p3; - this.color = Color.fromARGB(builder.argbColor); - this.doubleSided = builder.doubleSided; - this.blockLight = builder.blockLight; this.skyLight = builder.skyLight; - this.seeThrough = builder.seeThrough; - this.viewRange = builder.viewRange; + super(builder); + this.p1 = new Vector3f(builder.p1); + this.p2 = new Vector3f(builder.p2); + this.p3 = new Vector3f(builder.p3); } @Override - public void spawn() { - if (spawned) return; - for (TRSResult trs : TextDisplayUtil.computeTriangleTRS(p1, p2, p3)) { spawnTextDisplay(trs); } - if (doubleSided) { for (TRSResult trs : TextDisplayUtil.computeTriangleTRS(p1, p3, p2)) { spawnTextDisplay(trs); } } - spawned = true; + protected List computeTransforms() { + return ShapeGeometry.triangle(p1, p2, p3, isDoubleSided()); } - private void spawnTextDisplay(TRSResult trs) { - Vector3f adj = new Vector3f(trs.translation()).sub((float) origin.getX(), (float) origin.getY(), (float) origin.getZ()); - Transformation transformation = new Transformation(adj, trs.leftRotation(), trs.scale(), trs.rightRotation()); - TextDisplay display = origin.getWorld().spawn(origin, TextDisplay.class, (d) -> { - d.setText(" "); - d.setBackgroundColor(color); - d.setBrightness(new Display.Brightness(blockLight, skyLight)); - d.setTransformation(transformation); - d.setSeeThrough(seeThrough); - d.setViewRange(viewRange); - }); - displays.add(display); + @Override + protected void offsetGeometry(float dx, float dy, float dz) { + p1.add(dx, dy, dz); + p2.add(dx, dy, dz); + p3.add(dx, dy, dz); } @Override - public void remove() { - for (TextDisplay d : displays) { if (d.isValid()) d.remove(); } - displays.clear(); - spawned = false; + public Vector3f getPoint1() { + return new Vector3f(p1); } - @Override public boolean isSpawned() { return spawned; } - @Override public void addViewer(UUID playerUUID) { } - @Override public void removeViewer(UUID playerUUID) { } - @Override public Set getViewerUUIDs() { return new HashSet<>(); } - @Override - public List getEntityUUIDs() { - List uuids = new ArrayList<>(); - for (TextDisplay d : displays) uuids.add(d.getUniqueId()); - return uuids; + public Vector3f getPoint2() { + return new Vector3f(p2); } - public List getEntities() { return new ArrayList<>(displays); } + @Override + public Vector3f getPoint3() { + return new Vector3f(p3); + } @Override - public void teleportOrigin(double x, double y, double z) { - if (!spawned) return; - Location newOrigin = new Location(origin.getWorld(), x, y, z); - float dx = (float)(x - origin.getX()), dy = (float)(y - origin.getY()), dz = (float)(z - origin.getZ()); - for (TextDisplay display : displays) { - if (!display.isValid()) continue; - org.bukkit.util.Transformation t = display.getTransformation(); - org.joml.Vector3f tr = t.getTranslation(); - display.setTransformation(new org.bukkit.util.Transformation( - new org.joml.Vector3f(tr.x - dx, tr.y - dy, tr.z - dz), - t.getLeftRotation(), t.getScale(), t.getRightRotation())); - display.teleport(newOrigin); - } - this.origin = newOrigin.clone(); + public void setPoints(Vector3f point1, Vector3f point2, Vector3f point3) { + List transforms = ShapeGeometry.triangle(point1, point2, point3, isDoubleSided()); + p1.set(point1); + p2.set(point2); + p3.set(point3); + applyTransforms(transforms); } - public static class Builder implements ShapeBuilder { - private final Location origin; - private final Vector3f p1, p2, p3; - private int argbColor = Color.fromARGB(150, 50, 100, 100).asARGB(); - private boolean doubleSided = false; - private int blockLight = 15, skyLight = 15; - private boolean seeThrough = true; - private float viewRange = 1.0f; + public static class Builder extends AbstractBukkitShape.Builder { + private final Vector3f p1; + private final Vector3f p2; + private final Vector3f p3; public Builder(Location origin, Vector3f p1, Vector3f p2, Vector3f p3) { - this.origin = origin; this.p1 = p1; this.p2 = p2; this.p3 = p3; + super(origin); + this.p1 = p1; + this.p2 = p2; + this.p3 = p3; + } + + @Override + public BukkitTriangle build() { + return new BukkitTriangle(this); } - public Builder color(Color color) { this.argbColor = color.asARGB(); return this; } - @Override public Builder color(int argb) { this.argbColor = argb; return this; } - @Override public Builder doubleSided(boolean v) { this.doubleSided = v; return this; } - @Override public Builder brightness(int b, int s) { this.blockLight = b; this.skyLight = s; return this; } - @Override public Builder seeThrough(boolean v) { this.seeThrough = v; return this; } - @Override public Builder viewRange(float v) { this.viewRange = v; return this; } - @Override public BukkitTriangle build() { return new BukkitTriangle(this); } } }