distantViewers = HashMultimap.create();
+
+ public static boolean canSee(Player player, Hologram hologram) {
+ return hologram.isViewer(player) || distantViewers.containsEntry(hologram.getData().getName(), player.getUniqueId());
+ }
+
+ public static void addDistantViewer(Hologram hologram, UUID uuid) {
+ addDistantViewer(hologram.getData().getName(), uuid);
+ }
+
+ public static void addDistantViewer(String hologramName, UUID uuid) {
+ distantViewers.put(hologramName, uuid);
+ }
+
+ public static void removeDistantViewer(Hologram hologram, UUID uuid) {
+ removeDistantViewer(hologram.getData().getName(), uuid);
+ }
+
+ public static void removeDistantViewer(String hologramName, UUID uuid) {
+ distantViewers.remove(hologramName, uuid);
+ }
+
+ public static void remove(Hologram hologram) {
+ remove(hologram.getData().getName());
+ }
+
+ public static void remove(String hologramName) {
+ distantViewers.removeAll(hologramName);
+ }
+
+ public static void clear() {
+ distantViewers.clear();
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramCreateEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramCreateEvent.java
new file mode 100644
index 00000000..27238540
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramCreateEvent.java
@@ -0,0 +1,38 @@
+package de.oliver.fancyholograms.api.events;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.entity.Player;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a hologram is being created, any hologram data changed will be reflected in the new hologram
+ */
+public final class HologramCreateEvent extends HologramEvent {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+
+ @NotNull
+ private final Player player;
+
+ public HologramCreateEvent(@NotNull final Hologram hologram, @NotNull final Player player) {
+ super(hologram, false);
+
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull Player getPlayer() {
+ return this.player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramDeleteEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramDeleteEvent.java
new file mode 100644
index 00000000..3f65478c
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramDeleteEvent.java
@@ -0,0 +1,40 @@
+package de.oliver.fancyholograms.api.events;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.Bukkit;
+import org.bukkit.command.CommandSender;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a hologram is being deleted, any hologram data changed will be reflected in the hologram if
+ * the event is called
+ */
+public final class HologramDeleteEvent extends HologramEvent {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+
+ @NotNull
+ private final CommandSender player;
+
+ public HologramDeleteEvent(@NotNull final Hologram hologram, @NotNull final CommandSender player) {
+ super(hologram, false);
+
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull CommandSender getPlayer() {
+ return this.player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramDespawnEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramDespawnEvent.java
new file mode 100644
index 00000000..549ddd0a
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramDespawnEvent.java
@@ -0,0 +1,39 @@
+package de.oliver.fancyholograms.api.events;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a hologram is being hidden from a player
+ */
+public final class HologramDespawnEvent extends HologramEvent {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+
+ @NotNull
+ private final Player player;
+
+ public HologramDespawnEvent(@NotNull final Hologram hologram, @NotNull final Player player) {
+ super(hologram, !Bukkit.isPrimaryThread());
+
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull Player getPlayer() {
+ return this.player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramEvent.java
new file mode 100644
index 00000000..15090052
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramEvent.java
@@ -0,0 +1,47 @@
+package de.oliver.fancyholograms.api.events;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Represents a base event related to Holograms. This is an abstract class that other event classes related to Holograms should extend.
+ * This event is cancellable, which means it can be prevented from being processed by the server.
+ */
+public abstract class HologramEvent extends Event implements Cancellable {
+
+ @NotNull
+ private final Hologram hologram;
+
+
+ private boolean cancelled;
+
+
+ protected HologramEvent(@NotNull final Hologram hologram, final boolean isAsync) {
+ super(isAsync);
+ this.hologram = hologram;
+ }
+
+
+ /**
+ * Returns the hologram involved in this event.
+ *
+ * @return the hologram involved in this event
+ */
+ public final @NotNull Hologram getHologram() {
+ return this.hologram;
+ }
+
+
+ @Override
+ public final boolean isCancelled() {
+ return this.cancelled;
+ }
+
+ @Override
+ public final void setCancelled(final boolean cancel) {
+ this.cancelled = cancel;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramSpawnEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramSpawnEvent.java
new file mode 100644
index 00000000..2a432f15
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramSpawnEvent.java
@@ -0,0 +1,39 @@
+package de.oliver.fancyholograms.api.events;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a hologram is being shown to a player
+ */
+public final class HologramSpawnEvent extends HologramEvent {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+
+ @NotNull
+ private final Player player;
+
+ public HologramSpawnEvent(@NotNull final Hologram hologram, @NotNull final Player player) {
+ super(hologram, !Bukkit.isPrimaryThread());
+
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull Player getPlayer() {
+ return this.player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramUpdateEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramUpdateEvent.java
new file mode 100644
index 00000000..016e9f0c
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramUpdateEvent.java
@@ -0,0 +1,88 @@
+package de.oliver.fancyholograms.api.events;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.api.data.*;
+import org.bukkit.command.CommandSender;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a hologram is being updated, the data in the hologram is current and the event holds the new data
+ */
+public final class HologramUpdateEvent extends HologramEvent {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+ private final @NotNull CommandSender player;
+ private final @NotNull HologramData updatedData;
+ private final @NotNull HologramModification modification;
+
+ public HologramUpdateEvent(@NotNull final Hologram hologram, @NotNull final CommandSender player, @NotNull final HologramData updatedData, @NotNull final HologramModification modification) {
+ super(hologram, false);
+
+ this.player = player;
+ this.updatedData = updatedData;
+ this.modification = modification;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull CommandSender getPlayer() {
+ return this.player;
+ }
+
+ /**
+ * Returns the current data of the hologram.
+ *
+ * @return the current data of the hologram
+ */
+ public @NotNull HologramData getCurrentData() {
+ return getHologram().getData();
+ }
+
+ /**
+ * Returns the updated data of the hologram.
+ *
+ * @return the updated data of the hologram
+ */
+ public @NotNull HologramData getUpdatedData() {
+ return this.updatedData;
+ }
+
+ /**
+ * Returns the type of modification performed on the hologram.
+ *
+ * @return the type of modification performed on the hologram
+ */
+ public @NotNull HologramModification getModification() {
+ return this.modification;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+
+ /**
+ * Represents the various types of modifications that can be made to a Hologram.
+ */
+ public enum HologramModification {
+ TEXT,
+ POSITION,
+ SCALE,
+ TRANSLATION,
+ BILLBOARD,
+ BACKGROUND,
+ TEXT_SHADOW,
+ TEXT_ALIGNMENT,
+ SEE_THROUGH,
+ SHADOW_RADIUS,
+ SHADOW_STRENGTH,
+ UPDATE_TEXT_INTERVAL,
+ UPDATE_VISIBILITY_DISTANCE;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramsLoadEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramsLoadEvent.java
new file mode 100644
index 00000000..916255ba
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramsLoadEvent.java
@@ -0,0 +1,45 @@
+package de.oliver.fancyholograms.api.events;
+
+import com.google.common.collect.ImmutableList;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.Bukkit;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Represents an event triggered when all holograms are loaded.
+ * This event contains a list of all holograms that have been loaded in the current context.
+ * The event is asynchronous if it does not execute on the main server thread.
+ *
+ * This event may serve as a notification mechanism to inform listeners that the loading operation
+ * for holograms has completed.
+ *
+ * This event extends the {@link Event} class, utilizing the Bukkit event system.
+ */
+public final class HologramsLoadEvent extends Event {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+ private final ImmutableList holograms;
+
+ public HologramsLoadEvent(@NotNull final ImmutableList holograms) {
+ super(!Bukkit.isPrimaryThread());
+
+ this.holograms = holograms;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull ImmutableList getHolograms() {
+ return this.holograms;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramsUnloadEvent.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramsUnloadEvent.java
new file mode 100644
index 00000000..cacc9d4a
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/events/HologramsUnloadEvent.java
@@ -0,0 +1,41 @@
+package de.oliver.fancyholograms.api.events;
+
+import com.google.common.collect.ImmutableList;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.Bukkit;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Represents an event that is triggered when holograms are unloaded in the system.
+ * This event contains the list of holograms that are being unloaded.
+ *
+ * This event is not cancellable.
+ */
+public final class HologramsUnloadEvent extends Event {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+ private final ImmutableList holograms;
+
+ public HologramsUnloadEvent(@NotNull final ImmutableList holograms) {
+ super(!Bukkit.isPrimaryThread());
+
+ this.holograms = holograms;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull ImmutableList getHolograms() {
+ return this.holograms;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/hologram/Hologram.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/hologram/Hologram.java
new file mode 100644
index 00000000..4bceeb39
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/hologram/Hologram.java
@@ -0,0 +1,192 @@
+package de.oliver.fancyholograms.api.hologram;
+
+import com.google.common.collect.Sets;
+import de.oliver.fancyholograms.api.FancyHolograms;
+import de.oliver.fancyholograms.api.data.HologramData;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.trait.HologramTrait;
+import de.oliver.fancyholograms.api.trait.HologramTraitTrait;
+import net.kyori.adventure.text.Component;
+import org.bukkit.Color;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.lushplugins.chatcolorhandler.ModernChatColorHandler;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+
+/**
+ * This class provides core functionalities for managing viewers, spawning, despawning, and updating holograms.
+ */
+public abstract class Hologram {
+
+ public static final int LINE_WIDTH = 1000;
+ public static final Color TRANSPARENT = Color.fromARGB(0);
+ protected static final int MINIMUM_PROTOCOL_VERSION = 762;
+
+ protected final @NotNull HologramData data;
+ protected final @NotNull Set viewers;
+ protected final @NotNull HologramTraitTrait traitTrait;
+
+ protected Hologram(@NotNull final HologramData data) {
+ this.data = data;
+ this.viewers = new HashSet<>();
+ this.traitTrait = new HologramTraitTrait(this);
+ }
+
+ /**
+ * Forcefully spawns the hologram and makes it visible to the specified player.
+ *
+ * @param player the player to whom the hologram should be shown; must not be null
+ */
+ @ApiStatus.Internal
+ public abstract void spawnTo(@NotNull final Player player);
+
+ /**
+ * Forcefully despawns the hologram and makes it invisible to the specified player.
+ *
+ * @param player the player from whom the hologram should be hidden; must not be null
+ */
+ @ApiStatus.Internal
+ public abstract void despawnFrom(@NotNull final Player player);
+
+ /**
+ * Updates the hologram for the specified player.
+ *
+ * @param player the player for whom the hologram should be updated; must not be null
+ */
+ @ApiStatus.Internal
+ public abstract void updateFor(@NotNull final Player player);
+
+
+ /**
+ * @return a copy of the set of UUIDs of players currently viewing the hologram
+ */
+ public final @NotNull Set getViewers() {
+ return Sets.newHashSet(this.viewers);
+ }
+
+ @ApiStatus.Internal
+ public void setViewers(@NotNull final Set viewers) {
+ this.viewers.clear();
+ this.viewers.addAll(viewers);
+ }
+
+ @ApiStatus.Internal
+ public void removeViewer(@NotNull final UUID viewer) {
+ this.viewers.remove(viewer);
+ }
+
+ /**
+ * @param player the player to check for
+ * @return whether the player is currently viewing the hologram
+ */
+ public final boolean isViewer(@NotNull final Player player) {
+ return isViewer(player.getUniqueId());
+ }
+
+ /**
+ * @param player the uuid of the player to check for
+ * @return whether the player is currently viewing the hologram
+ */
+ public final boolean isViewer(@NotNull final UUID player) {
+ return this.viewers.contains(player);
+ }
+
+ @ApiStatus.Experimental
+ public @NotNull HologramTraitTrait getTraitTrait() {
+ return traitTrait;
+ }
+
+ @ApiStatus.Experimental
+ public HologramData addTrait(HologramTrait trait) {
+ traitTrait.addTrait(trait);
+ return data;
+ }
+
+ @ApiStatus.Experimental
+ public HologramData addTrait(Class extends HologramTrait> traitClass) {
+ HologramTrait trait = null;
+ try {
+ trait = traitClass.getConstructor(null).newInstance();
+ } catch (InstantiationException | IllegalAccessException | InvocationTargetException |
+ NoSuchMethodException e) {
+ FancyHolograms.get().getFancyLogger().error("Failed to instantiate trait " + traitClass.getSimpleName());
+ FancyHolograms.get().getFancyLogger().error(e);
+ }
+
+ traitTrait.addTrait(trait);
+ return data;
+ }
+
+ public final @NotNull HologramData getData() {
+ return this.data;
+ }
+
+ /**
+ * Retrieves the data associated with the hologram and casts it to the specified type.
+ *
+ * @param the type of {@code HologramData} to retrieve
+ * @param clazz the class of the data type to retrieve; must not be null
+ * @return the hologram data cast to the specified type
+ */
+ @ApiStatus.Experimental
+ public final @NotNull T getData(@NotNull Class clazz) {
+ return clazz.cast(this.data);
+ }
+
+ /**
+ * Retrieves the data associated with the hologram, if it can be cast to the specified type.
+ *
+ * @param the type of {@code HologramData}
+ * @param clazz the class of the data type to retrieve; must not be null
+ * @return the hologram data cast to the specified type, or null if the cast fails
+ */
+ @ApiStatus.Experimental
+ public final @Nullable T getDataNullable(@NotNull Class clazz) {
+ try {
+ return clazz.cast(this.data);
+ } catch (ClassCastException ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * Consumes the data associated with the hologram if it can be cast to the specified type.
+ *
+ * @param the type of {@link HologramData} to consume
+ * @param clazz the class of the data type to consume; must not be null
+ * @param consumer the action to perform with the consumed data; must not be null
+ */
+ @ApiStatus.Experimental
+ public final void consumeData(@NotNull Class clazz, @NotNull Consumer consumer) {
+ final T data = getDataNullable(clazz);
+
+ if (data != null) {
+ consumer.accept(data);
+ }
+ }
+
+ /**
+ * Gets the text shown in the hologram. If a player is specified, placeholders in the text are replaced
+ * with their corresponding values for the player.
+ *
+ * @param player the player to get the placeholders for, or null if no placeholders should be replaced
+ * @return the text shown in the hologram
+ */
+ public final Component getShownText(@Nullable final Player player) {
+ if (!(getData() instanceof TextHologramData textData)) {
+ return null;
+ }
+
+ var text = String.join("\n", textData.getText());
+
+ return ModernChatColorHandler.translate(text, player);
+ }
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/hologram/HologramType.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/hologram/HologramType.java
new file mode 100644
index 00000000..432fcdd8
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/hologram/HologramType.java
@@ -0,0 +1,31 @@
+package de.oliver.fancyholograms.api.hologram;
+
+import java.util.Arrays;
+import java.util.List;
+
+public enum HologramType {
+ TEXT(Arrays.asList("background", "textshadow", "textalignment", "seethrough", "setline", "removeline", "addline", "insertbefore", "insertafter", "updatetextinterval")),
+ ITEM(List.of("item")),
+ BLOCK(List.of("block"));
+
+ private final List commands;
+
+ HologramType(List commands) {
+ this.commands = commands;
+ }
+
+ public static HologramType getByName(String name) {
+ for (HologramType type : values()) {
+ if (type.name().equalsIgnoreCase(name)) {
+ return type;
+ }
+ }
+
+ return null;
+ }
+
+ public List getCommands() {
+ return commands;
+ }
+
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/DefaultTrait.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/DefaultTrait.java
new file mode 100644
index 00000000..fd4db755
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/DefaultTrait.java
@@ -0,0 +1,8 @@
+package de.oliver.fancyholograms.api.trait;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@Retention(RetentionPolicy.RUNTIME)
+public @interface DefaultTrait {
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTrait.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTrait.java
new file mode 100644
index 00000000..cbbabd99
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTrait.java
@@ -0,0 +1,106 @@
+package de.oliver.fancyholograms.api.trait;
+
+import de.oliver.fancyanalytics.logger.ExtendedFancyLogger;
+import de.oliver.fancyholograms.api.FancyHolograms;
+import de.oliver.fancyholograms.api.HologramController;
+import de.oliver.fancyholograms.api.HologramRegistry;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Represents a trait that can be attached to a hologram. This class provides a structure for
+ * managing the lifecycle of traits related to holograms. It defines methods to handle
+ * initialization, attachment, updates, and data persistence.
+ *
+ * Subclasses of this abstract class must implement the specific behavior of the trait by
+ * overriding the provided lifecycle methods.
+ */
+@ApiStatus.Experimental
+public abstract class HologramTrait {
+
+ protected final String name;
+ protected final FancyHolograms api = FancyHolograms.get();
+ protected final ExtendedFancyLogger logger = api.getFancyLogger();
+ protected final HologramController controller = api.getController();
+ protected final HologramRegistry registry = api.getRegistry();
+ protected final ScheduledExecutorService hologramThread = api.getHologramThread();
+ protected Hologram hologram;
+
+
+ /**
+ * Creates a new hologram trait with the given name.
+ * @param name the name of the trait
+ */
+ public HologramTrait(String name) {
+ this.name = name;
+ }
+
+ public HologramTrait() {
+ this.name = getClass().getSimpleName();
+ }
+
+ public void attachHologram(Hologram hologram) {
+ if (this.hologram != null) {
+ throw new IllegalStateException("Trait is already attached to a hologram");
+ }
+
+ this.hologram = hologram;
+ }
+
+ /**
+ * Called when the trait is attached to a hologram.
+ * The hologram is available at this point.
+ */
+ public void onAttach() {
+ }
+
+ /**
+ * Called when the hologram is spawned to a player.
+ */
+ public void onSpawn(Player player) {
+ }
+
+ /**
+ * Called when the hologram is despawned from a player.
+ */
+ public void onDespawn(Player player) {
+ }
+
+ /**
+ * Called when the hologram is registered in the registry.
+ */
+ public void onRegister() {
+
+ }
+
+ /**
+ * Called when the hologram is unregistered from the registry.
+ */
+ public void onUnregister() {
+ }
+
+ /**
+ * Called when the hologram is being loaded.
+ * In this method you should load all necessary data for the trait.
+ */
+ public void load() {
+ }
+
+ /**
+ * Called when the hologram is being saved.
+ * In this method you should save all necessary data for the trait.
+ */
+ public void save() {
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Hologram getHologram() {
+ return hologram;
+ }
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTraitRegistry.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTraitRegistry.java
new file mode 100644
index 00000000..9936c7a9
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTraitRegistry.java
@@ -0,0 +1,21 @@
+package de.oliver.fancyholograms.api.trait;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.List;
+
+@ApiStatus.Experimental
+public interface HologramTraitRegistry {
+
+ @ApiStatus.Experimental
+ boolean register(Class extends HologramTrait> trait);
+
+ @ApiStatus.Experimental
+ boolean unregister(Class extends HologramTrait> trait);
+
+ @ApiStatus.Experimental
+ boolean isRegistered(Class extends HologramTrait> trait);
+
+ @ApiStatus.Experimental
+ List> getRegisteredTraits();
+}
diff --git a/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTraitTrait.java b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTraitTrait.java
new file mode 100644
index 00000000..cb4b9362
--- /dev/null
+++ b/plugins/fancyholograms/api/src/main/java/de/oliver/fancyholograms/api/trait/HologramTraitTrait.java
@@ -0,0 +1,86 @@
+package de.oliver.fancyholograms.api.trait;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class HologramTraitTrait extends HologramTrait {
+
+ private final List traits;
+
+ public HologramTraitTrait(Hologram hologram) {
+ super("trait");
+ attachHologram(hologram);
+ this.traits = new ArrayList<>();
+ }
+
+ public void addTrait(HologramTrait trait) {
+ this.traits.add(trait);
+ trait.attachHologram(hologram);
+ trait.onAttach();
+ }
+
+ @Override
+ public void onAttach() {
+ List> registeredTraits = api.getTraitRegistry().getRegisteredTraits();
+ for (Class extends HologramTrait> traitClass : registeredTraits) {
+ if (!traitClass.isAnnotationPresent(DefaultTrait.class)) {
+ continue;
+ }
+
+ try {
+ HologramTrait trait = traitClass.getConstructor().newInstance();
+ this.traits.add(trait);
+ logger.debug("Attached default trait " + traitClass.getName() + " to hologram " + hologram.getData().getName());
+ trait.onAttach();
+ } catch (Exception e) {
+ logger.error("Failed to instantiate trait " + traitClass.getName());
+ logger.error(e);
+ }
+ }
+ }
+
+ @Override
+ public void onSpawn(Player player) {
+ for (HologramTrait trait : this.traits) {
+ trait.onSpawn(player);
+ }
+ }
+
+ @Override
+ public void onDespawn(Player player) {
+ for (HologramTrait trait : this.traits) {
+ trait.onDespawn(player);
+ }
+ }
+
+ @Override
+ public void onRegister() {
+ for (HologramTrait trait : this.traits) {
+ trait.onRegister();
+ }
+ }
+
+ @Override
+ public void onUnregister() {
+ for (HologramTrait trait : this.traits) {
+ trait.onUnregister();
+ }
+ }
+
+ @Override
+ public void load() {
+ for (HologramTrait trait : this.traits) {
+ trait.load();
+ }
+ }
+
+ @Override
+ public void save() {
+ for (HologramTrait trait : this.traits) {
+ trait.save();
+ }
+ }
+}
diff --git a/plugins/fancyholograms/build.gradle.kts b/plugins/fancyholograms/build.gradle.kts
new file mode 100644
index 00000000..33ad3ecb
--- /dev/null
+++ b/plugins/fancyholograms/build.gradle.kts
@@ -0,0 +1,264 @@
+import net.minecrell.pluginyml.bukkit.BukkitPluginDescription
+import net.minecrell.pluginyml.paper.PaperPluginDescription
+import java.io.BufferedReader
+import java.io.InputStreamReader
+
+plugins {
+ id("java-library")
+ id("maven-publish")
+
+ id("xyz.jpenilla.run-paper") version "2.3.1"
+ id("com.gradleup.shadow") version "8.3.6"
+ id("net.minecrell.plugin-yml.paper") version "0.6.0"
+ id("io.papermc.hangar-publish-plugin") version "0.1.2"
+ id("com.modrinth.minotaur") version "2.+"
+}
+
+runPaper.folia.registerTask()
+
+val supportedVersions =
+ listOf(
+ "1.19.4",
+ "1.20",
+ "1.20.1",
+ "1.20.2",
+ "1.20.3",
+ "1.20.4",
+ "1.20.5",
+ "1.20.6",
+ "1.21",
+ "1.21.1",
+ "1.21.2",
+ "1.21.3",
+ "1.21.4",
+ )
+
+allprojects {
+ group = "de.oliver"
+ val buildId = System.getenv("BUILD_ID")
+ version = "2.4.2" + (if (buildId != null) ".$buildId" else "")
+ description = "Simple, lightweight and fast hologram plugin using display entities"
+
+ repositories {
+ mavenLocal()
+ mavenCentral()
+
+ maven(url = "https://repo.papermc.io/repository/maven-public/")
+ maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
+
+ maven(url = "https://repo.fancyplugins.de/snapshots")
+ maven(url = "https://repo.fancyplugins.de/releases")
+ maven(url = "https://repo.lushplugins.org/releases")
+ maven(url = "https://repo.viaversion.com/")
+ maven(url = "https://repo.opencollab.dev/main/")
+ }
+}
+
+dependencies {
+ compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
+
+ implementation(project(":plugins:fancyholograms::api"))
+ implementation(project(":plugins:fancyholograms::implementation_1_20_4", configuration = "reobf"))
+ implementation(project(":plugins:fancyholograms::implementation_1_20_2", configuration = "reobf"))
+ implementation(project(":plugins:fancyholograms::implementation_1_20_1", configuration = "reobf"))
+ implementation(project(":plugins:fancyholograms::implementation_1_19_4", configuration = "reobf"))
+
+ implementation("de.oliver:FancyLib:35")
+ implementation("de.oliver:FancySitula:0.0.13")
+ implementation("de.oliver.FancyAnalytics:api:0.1.6")
+ implementation("de.oliver.FancyAnalytics:logger:0.0.6")
+
+ compileOnly("de.oliver:FancyNpcs:2.4.2")
+ compileOnly("org.lushplugins:ChatColorHandler:5.1.2")
+ compileOnly("com.viaversion:viaversion-api:5.2.0")
+ compileOnly("org.geysermc.floodgate:api:2.2.4-SNAPSHOT")
+}
+
+paper {
+ main = "de.oliver.fancyholograms.main.FancyHologramsPlugin"
+ bootstrapper = "de.oliver.fancyholograms.main.FancyHologramsBootstrapper"
+ loader = "de.oliver.fancyholograms.main.FancyHologramsLoader"
+ foliaSupported = true
+ version = rootProject.version.toString()
+ description = "Simple, lightweight and fast hologram plugin using display entities"
+ apiVersion = "1.19"
+ load = BukkitPluginDescription.PluginLoadOrder.POSTWORLD
+ serverDependencies {
+ register("FancyNpcs") {
+ required = false
+ load = PaperPluginDescription.RelativeLoadOrder.BEFORE
+ }
+ register("MiniPlaceholders") {
+ required = false
+ load = PaperPluginDescription.RelativeLoadOrder.BEFORE
+ }
+ register("PlaceholderAPI") {
+ required = false
+ load = PaperPluginDescription.RelativeLoadOrder.BEFORE
+ }
+ register("floodgate") {
+ required = false
+ load = PaperPluginDescription.RelativeLoadOrder.BEFORE
+ joinClasspath = true
+ }
+ register("ViaVersion") {
+ required = false
+ load = PaperPluginDescription.RelativeLoadOrder.BEFORE
+ joinClasspath = true
+ }
+ }
+}
+
+tasks {
+ runServer {
+ minecraftVersion("1.21.4")
+
+ downloadPlugins {
+ modrinth("fancynpcs", "2.4.0")
+ hangar("ViaVersion", "5.2.0")
+ hangar("ViaBackwards", "5.2.0")
+// modrinth("multiverse-core", "4.3.11")
+ hangar("PlaceholderAPI", "2.11.6")
+// modrinth("DecentHolograms", "2.8.12")
+ }
+ }
+
+ shadowJar {
+ archiveClassifier.set("")
+
+ dependsOn(":api:shadowJar")
+ }
+
+ publishing {
+ repositories {
+ maven {
+ name = "fancypluginsReleases"
+ url = uri("https://repo.fancyplugins.de/releases")
+ credentials(PasswordCredentials::class)
+ authentication {
+ isAllowInsecureProtocol = true
+ create("basic")
+ }
+ }
+
+ maven {
+ name = "fancypluginsSnapshots"
+ url = uri("https://repo.fancyplugins.de/snapshots")
+ credentials(PasswordCredentials::class)
+ authentication {
+ isAllowInsecureProtocol = true
+ create("basic")
+ }
+ }
+ }
+ publications {
+ create("maven") {
+ groupId = project.group.toString()
+ artifactId = project.name
+ version = project.version.toString()
+ from(project.components["java"])
+ }
+ }
+ }
+
+ compileJava {
+ options.encoding = Charsets.UTF_8.name() // We want UTF-8 for everything
+ options.release = 21
+ // For cloud-annotations, see https://cloud.incendo.org/annotations/#command-components
+ options.compilerArgs.add("-parameters")
+ }
+
+ javadoc {
+ options.encoding = Charsets.UTF_8.name() // We want UTF-8 for everything
+ }
+
+ processResources {
+ filteringCharset = Charsets.UTF_8.name() // We want UTF-8 for everything
+
+ val props = mapOf(
+ "description" to project.description,
+ "version" to project.version,
+ "hash" to getCurrentCommitHash(),
+ "build" to (System.getenv("BUILD_ID") ?: "").ifEmpty { "undefined" }
+ )
+
+ inputs.properties(props)
+
+ filesMatching("paper-plugin.yml") {
+ expand(props)
+ }
+
+ filesMatching("version.yml") {
+ expand(props)
+ }
+ }
+}
+
+tasks.publishAllPublicationsToHangar {
+ dependsOn("shadowJar")
+}
+
+tasks.modrinth {
+ dependsOn("shadowJar")
+}
+
+java {
+ toolchain.languageVersion.set(JavaLanguageVersion.of(21))
+}
+
+fun getCurrentCommitHash(): String {
+ val process = ProcessBuilder("git", "rev-parse", "HEAD").start()
+ val reader = BufferedReader(InputStreamReader(process.inputStream))
+ val commitHash = reader.readLine()
+ reader.close()
+ process.waitFor()
+ if (process.exitValue() == 0) {
+ return commitHash ?: ""
+ } else {
+ throw IllegalStateException("Failed to retrieve the commit hash.")
+ }
+}
+
+fun getLastCommitMessage(): String {
+ val process = ProcessBuilder("git", "log", "-1", "--pretty=%B").start()
+ val reader = BufferedReader(InputStreamReader(process.inputStream))
+ val commitMessage = reader.readLine()
+ reader.close()
+ process.waitFor()
+ if (process.exitValue() == 0) {
+ return commitMessage ?: ""
+ } else {
+ throw IllegalStateException("Failed to retrieve the commit message.")
+ }
+}
+
+hangarPublish {
+ publications.register("plugin") {
+ version = project.version as String
+ id = "FancyHolograms"
+ channel = "Alpha"
+
+ apiKey.set(System.getenv("HANGAR_PUBLISH_API_TOKEN"))
+
+ platforms {
+ paper {
+ jar = tasks.shadowJar.flatMap { it.archiveFile }
+ platformVersions = supportedVersions
+ }
+ }
+
+ changelog = getLastCommitMessage()
+ }
+}
+
+modrinth {
+ token.set(System.getenv("MODRINTH_PUBLISH_API_TOKEN"))
+ projectId.set("fancyholograms")
+ versionNumber.set(project.version.toString())
+ versionType.set("alpha")
+ uploadFile.set(file("build/libs/${project.name}-${project.version}.jar"))
+ gameVersions.addAll(supportedVersions)
+ loaders.add("paper")
+ loaders.add("folia")
+ changelog.set(getLastCommitMessage())
+}
\ No newline at end of file
diff --git a/plugins/fancyholograms/images/banner.png b/plugins/fancyholograms/images/banner.png
new file mode 100644
index 00000000..60f5ec7a
Binary files /dev/null and b/plugins/fancyholograms/images/banner.png differ
diff --git a/plugins/fancyholograms/images/screenshots/example1.jpeg b/plugins/fancyholograms/images/screenshots/example1.jpeg
new file mode 100644
index 00000000..97cc97d3
Binary files /dev/null and b/plugins/fancyholograms/images/screenshots/example1.jpeg differ
diff --git a/plugins/fancyholograms/images/screenshots/example2.jpeg b/plugins/fancyholograms/images/screenshots/example2.jpeg
new file mode 100644
index 00000000..7dd724e4
Binary files /dev/null and b/plugins/fancyholograms/images/screenshots/example2.jpeg differ
diff --git a/plugins/fancyholograms/images/screenshots/example3.jpeg b/plugins/fancyholograms/images/screenshots/example3.jpeg
new file mode 100644
index 00000000..db23574a
Binary files /dev/null and b/plugins/fancyholograms/images/screenshots/example3.jpeg differ
diff --git a/plugins/fancyholograms/images/screenshots/example4.jpeg b/plugins/fancyholograms/images/screenshots/example4.jpeg
new file mode 100644
index 00000000..6f647d41
Binary files /dev/null and b/plugins/fancyholograms/images/screenshots/example4.jpeg differ
diff --git a/plugins/fancyholograms/images/screenshots/example5.jpeg b/plugins/fancyholograms/images/screenshots/example5.jpeg
new file mode 100644
index 00000000..cce15620
Binary files /dev/null and b/plugins/fancyholograms/images/screenshots/example5.jpeg differ
diff --git a/plugins/fancyholograms/implementation_1_19_4/build.gradle.kts b/plugins/fancyholograms/implementation_1_19_4/build.gradle.kts
new file mode 100644
index 00000000..3b8b11a4
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_19_4/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ id("java-library")
+ id("io.papermc.paperweight.userdev") version "1.7.7"
+}
+
+
+val minecraftVersion = "1.19.4"
+
+
+dependencies {
+ paperweight.paperDevBundle("$minecraftVersion-R0.1-SNAPSHOT")
+
+ implementation(project(":plugins:fancyholograms:api"))
+ implementation("de.oliver:FancyLib:35")
+ compileOnly("com.viaversion:viaversion-api:5.2.1")
+}
+
+
+tasks {
+ named("assemble") {
+ dependsOn(named("reobfJar"))
+ }
+
+ javadoc {
+ options.encoding = Charsets.UTF_8.name()
+ }
+
+ compileJava {
+ options.encoding = Charsets.UTF_8.name()
+
+ options.release.set(17)
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancyholograms/implementation_1_19_4/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_19_4.java b/plugins/fancyholograms/implementation_1_19_4/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_19_4.java
new file mode 100644
index 00000000..2179fba4
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_19_4/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_19_4.java
@@ -0,0 +1,253 @@
+package de.oliver.fancyholograms.hologram.version;
+
+import com.mojang.math.Transformation;
+import de.oliver.fancyholograms.api.data.*;
+import de.oliver.fancyholograms.api.events.HologramDespawnEvent;
+import de.oliver.fancyholograms.api.events.HologramSpawnEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancylib.ReflectionUtils;
+import io.papermc.paper.adventure.PaperAdventure;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
+import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.SynchedEntityData.DataItem;
+import net.minecraft.network.syncher.SynchedEntityData.DataValue;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.Brightness;
+import net.minecraft.world.entity.Display;
+import net.minecraft.world.entity.Display.TextDisplay;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.Block;
+import org.bukkit.craftbukkit.v1_19_R3.CraftWorld;
+import org.bukkit.craftbukkit.v1_19_R3.entity.CraftPlayer;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaternionf;
+
+import java.util.ArrayList;
+
+import static de.oliver.fancylib.ReflectionUtils.getValue;
+
+public final class Hologram1_19_4 extends Hologram {
+
+ @Nullable
+ private Display display;
+
+ public Hologram1_19_4(@NotNull final HologramData data) {
+ super(data);
+
+ create();
+ }
+
+ public void create() {
+ final var location = data.getLocation();
+ if (location.getWorld() == null) {
+ return; // no location data, cannot be created
+ }
+
+ ServerLevel world = ((CraftWorld) location.getWorld()).getHandle();
+
+ switch (data.getType()) {
+ case TEXT -> this.display = new Display.TextDisplay(EntityType.TEXT_DISPLAY, world);
+ case BLOCK -> this.display = new Display.BlockDisplay(EntityType.BLOCK_DISPLAY, world);
+ case ITEM -> this.display = new Display.ItemDisplay(EntityType.ITEM_DISPLAY, world);
+ }
+ }
+
+ public void syncWithData() {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to update
+ }
+
+ // location data
+ final var location = data.getLocation();
+ if (!location.isWorldLoaded()) {
+ return;
+ } else {
+ display.setPosRaw(location.x(), location.y(), location.z());
+ display.setYRot(location.getYaw());
+ display.setXRot(location.getPitch());
+ }
+
+ if (display instanceof TextDisplay textDisplay && data instanceof TextHologramData textData) {
+ // line width
+ final var DATA_LINE_WIDTH_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_19_4.DATA_LINE_WIDTH_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_LINE_WIDTH_ID, Hologram.LINE_WIDTH);
+
+ // background
+ final var DATA_BACKGROUND_COLOR_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_19_4.DATA_BACKGROUND_COLOR_ID.getMapping());
+
+ final var background = textData.getBackground();
+ if (background == null) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, TextDisplay.INITIAL_BACKGROUND);
+ } else if (background == Hologram.TRANSPARENT) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, 0);
+ } else {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, background.asARGB());
+ }
+
+ // text shadow
+ if (textData.hasTextShadow()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SHADOW));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SHADOW));
+ }
+
+ // text alignment
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.LEFT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_LEFT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_LEFT));
+ }
+
+ // see through
+ if (textData.isSeeThrough()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SEE_THROUGH));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SEE_THROUGH));
+ }
+
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.RIGHT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_RIGHT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_RIGHT));
+ }
+
+ } else if (display instanceof Display.ItemDisplay itemDisplay && data instanceof ItemHologramData itemData) {
+ // item
+ itemDisplay.setItemStack(ItemStack.fromBukkitCopy(itemData.getItemStack()));
+
+ } else if (display instanceof Display.BlockDisplay blockDisplay && data instanceof BlockHologramData blockData) {
+ Block block = BuiltInRegistries.BLOCK.get(ResourceLocation.of("minecraft:" + blockData.getBlock().name().toLowerCase(), ':'));
+ blockDisplay.setBlockState(block.defaultBlockState());
+ }
+
+ if (data instanceof DisplayHologramData displayData) {
+ // interpolation
+ final var DATA_INTERPOLATION_DURATION_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_19_4.DATA_INTERPOLATION_DURATION_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_DURATION_ID, displayData.getInterpolationDuration());
+
+ final var DATA_INTERPOLATION_START_DELTA_TICKS_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_19_4.DATA_INTERPOLATION_START_DELTA_TICKS_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_START_DELTA_TICKS_ID, 0);
+
+ // billboard data
+ display.setBillboardConstraints(switch (displayData.getBillboard()) {
+ case FIXED -> Display.BillboardConstraints.FIXED;
+ case VERTICAL -> Display.BillboardConstraints.VERTICAL;
+ case HORIZONTAL -> Display.BillboardConstraints.HORIZONTAL;
+ case CENTER -> Display.BillboardConstraints.CENTER;
+ });
+
+ // brightness
+ if (displayData.getBrightness() != null) {
+ display.setBrightnessOverride(new Brightness(displayData.getBrightness().getBlockLight(), displayData.getBrightness().getSkyLight()));
+ }
+
+ // entity scale AND MORE!
+ display.setTransformation(new Transformation(
+ displayData.getTranslation(),
+ new Quaternionf(),
+ displayData.getScale(),
+ new Quaternionf())
+ );
+
+ // entity shadow
+ display.setShadowRadius(displayData.getShadowRadius());
+ display.setShadowStrength(displayData.getShadowStrength());
+
+ // view range
+ display.setViewRange(displayData.getVisibilityDistance());
+ }
+ }
+
+
+ @Override
+ public void spawnTo(@NotNull final Player player) {
+ if (!new HologramSpawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ if (this.display == null) {
+ create(); // try to create it if it doesn't exist every time
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // could not be created, nothing to show
+ }
+
+ if (!data.getLocation().getWorld().getName().equals(player.getLocation().getWorld().getName())) {
+ return;
+ }
+
+ ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
+
+ // TODO: cache player protocol version
+ // TODO: fix this
+// final var protocolVersion = FancyHologramsPlugin.get().isUsingViaVersion() ? Via.getAPI().getPlayerVersion(player) : MINIMUM_PROTOCOL_VERSION;
+// if (protocolVersion < MINIMUM_PROTOCOL_VERSION) {
+// return false;
+// }
+
+ serverPlayer.connection.send(new ClientboundAddEntityPacket(display));
+ this.viewers.add(player.getUniqueId());
+ updateFor(player);
+
+ }
+
+ @Override
+ public void despawnFrom(@NotNull final Player player) {
+ if (!new HologramDespawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to hide
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundRemoveEntitiesPacket(display.getId()));
+
+ this.viewers.remove(player.getUniqueId());
+ }
+
+
+ @Override
+ public void updateFor(@NotNull final Player player) {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to refresh
+ }
+
+ if (!isViewer(player)) {
+ return;
+ }
+
+ syncWithData();
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundTeleportEntityPacket(display));
+
+ if (display instanceof TextDisplay textDisplay) {
+ textDisplay.setText(PaperAdventure.asVanilla(getShownText(player)));
+ }
+
+ final var values = new ArrayList>();
+
+ //noinspection unchecked
+ for (final var item : ((Int2ObjectMap>) getValue(display.getEntityData(), "e")).values()) {
+ values.add(item.value());
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundSetEntityDataPacket(display.getId(), values));
+ }
+
+}
diff --git a/plugins/fancyholograms/implementation_1_19_4/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_19_4.java b/plugins/fancyholograms/implementation_1_19_4/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_19_4.java
new file mode 100644
index 00000000..75115d4a
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_19_4/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_19_4.java
@@ -0,0 +1,20 @@
+package de.oliver.fancyholograms.hologram.version;
+
+public enum MappingKeys1_19_4 {
+
+ DATA_INTERPOLATION_DURATION_ID("r"),
+ DATA_INTERPOLATION_START_DELTA_TICKS_ID("q"),
+ DATA_LINE_WIDTH_ID("aL"),
+ DATA_BACKGROUND_COLOR_ID("aM"),
+ ;
+
+ private final String mapping;
+
+ MappingKeys1_19_4(String mapping) {
+ this.mapping = mapping;
+ }
+
+ public String getMapping() {
+ return mapping;
+ }
+}
diff --git a/plugins/fancyholograms/implementation_1_20_1/build.gradle.kts b/plugins/fancyholograms/implementation_1_20_1/build.gradle.kts
new file mode 100644
index 00000000..305839c0
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_1/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ id("java-library")
+ id("io.papermc.paperweight.userdev") version "1.7.7"
+}
+
+
+val minecraftVersion = "1.20.1"
+
+
+dependencies {
+ paperweight.paperDevBundle("$minecraftVersion-R0.1-SNAPSHOT")
+
+ implementation(project(":plugins:fancyholograms:api"))
+ implementation("de.oliver:FancyLib:35")
+ compileOnly("com.viaversion:viaversion-api:5.2.1")
+}
+
+
+tasks {
+ named("assemble") {
+ dependsOn(named("reobfJar"))
+ }
+
+ javadoc {
+ options.encoding = Charsets.UTF_8.name()
+ }
+
+ compileJava {
+ options.encoding = Charsets.UTF_8.name()
+
+ options.release.set(17)
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancyholograms/implementation_1_20_1/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_1.java b/plugins/fancyholograms/implementation_1_20_1/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_1.java
new file mode 100644
index 00000000..19fb5467
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_1/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_1.java
@@ -0,0 +1,253 @@
+package de.oliver.fancyholograms.hologram.version;
+
+import com.mojang.math.Transformation;
+import de.oliver.fancyholograms.api.data.*;
+import de.oliver.fancyholograms.api.events.HologramDespawnEvent;
+import de.oliver.fancyholograms.api.events.HologramSpawnEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancylib.ReflectionUtils;
+import io.papermc.paper.adventure.PaperAdventure;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
+import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.SynchedEntityData.DataItem;
+import net.minecraft.network.syncher.SynchedEntityData.DataValue;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.Brightness;
+import net.minecraft.world.entity.Display;
+import net.minecraft.world.entity.Display.TextDisplay;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.Block;
+import org.bukkit.craftbukkit.v1_20_R1.CraftWorld;
+import org.bukkit.craftbukkit.v1_20_R1.entity.CraftPlayer;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaternionf;
+
+import java.util.ArrayList;
+
+import static de.oliver.fancylib.ReflectionUtils.getValue;
+
+public final class Hologram1_20_1 extends Hologram {
+
+ @Nullable
+ private Display display;
+
+ public Hologram1_20_1(@NotNull final HologramData data) {
+ super(data);
+
+ create();
+ }
+
+ public void create() {
+ final var location = data.getLocation();
+ if (location.getWorld() == null) {
+ return; // no location data, cannot be created
+ }
+
+ ServerLevel world = ((CraftWorld) location.getWorld()).getHandle();
+
+ switch (data.getType()) {
+ case TEXT -> this.display = new Display.TextDisplay(EntityType.TEXT_DISPLAY, world);
+ case BLOCK -> this.display = new Display.BlockDisplay(EntityType.BLOCK_DISPLAY, world);
+ case ITEM -> this.display = new Display.ItemDisplay(EntityType.ITEM_DISPLAY, world);
+ }
+ }
+
+ public void syncWithData() {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to update
+ }
+
+ // location data
+ final var location = data.getLocation();
+ if (!location.isWorldLoaded()) {
+ return;
+ } else {
+ display.setPosRaw(location.x(), location.y(), location.z());
+ display.setYRot(location.getYaw());
+ display.setXRot(location.getPitch());
+ }
+
+ if (display instanceof TextDisplay textDisplay && data instanceof TextHologramData textData) {
+ // line width
+ final var DATA_LINE_WIDTH_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_20_1.DATA_LINE_WIDTH_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_LINE_WIDTH_ID, Hologram.LINE_WIDTH);
+
+ // background
+ final var DATA_BACKGROUND_COLOR_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_20_1.DATA_BACKGROUND_COLOR_ID.getMapping()); //DATA_BACKGROUND_COLOR_ID
+
+ final var background = textData.getBackground();
+ if (background == null) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, TextDisplay.INITIAL_BACKGROUND);
+ } else if (background == Hologram.TRANSPARENT) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, 0);
+ } else {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, background.asARGB());
+ }
+
+ // text shadow
+ if (textData.hasTextShadow()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SHADOW));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SHADOW));
+ }
+
+ // text alignment
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.LEFT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_LEFT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_LEFT));
+ }
+
+ // see through
+ if (textData.isSeeThrough()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SEE_THROUGH));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SEE_THROUGH));
+ }
+
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.RIGHT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_RIGHT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_RIGHT));
+ }
+
+ } else if (display instanceof Display.ItemDisplay itemDisplay && data instanceof ItemHologramData itemData) {
+ // item
+ itemDisplay.setItemStack(ItemStack.fromBukkitCopy(itemData.getItemStack()));
+
+ } else if (display instanceof Display.BlockDisplay blockDisplay && data instanceof BlockHologramData blockData) {
+ Block block = BuiltInRegistries.BLOCK.get(ResourceLocation.of("minecraft:" + blockData.getBlock().name().toLowerCase(), ':'));
+ blockDisplay.setBlockState(block.defaultBlockState());
+ }
+
+ if (data instanceof DisplayHologramData displayData) {
+ // interpolation
+ final var DATA_INTERPOLATION_DURATION_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_20_1.DATA_INTERPOLATION_DURATION_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_DURATION_ID, displayData.getInterpolationDuration());
+
+ final var DATA_INTERPOLATION_START_DELTA_TICKS_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_20_1.DATA_INTERPOLATION_START_DELTA_TICKS_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_START_DELTA_TICKS_ID, 0);
+
+ // billboard data
+ display.setBillboardConstraints(switch (displayData.getBillboard()) {
+ case FIXED -> Display.BillboardConstraints.FIXED;
+ case VERTICAL -> Display.BillboardConstraints.VERTICAL;
+ case HORIZONTAL -> Display.BillboardConstraints.HORIZONTAL;
+ case CENTER -> Display.BillboardConstraints.CENTER;
+ });
+
+ // brightness
+ if (displayData.getBrightness() != null) {
+ display.setBrightnessOverride(new Brightness(displayData.getBrightness().getBlockLight(), displayData.getBrightness().getSkyLight()));
+ }
+
+ // entity scale AND MORE!
+ display.setTransformation(new Transformation(
+ displayData.getTranslation(),
+ new Quaternionf(),
+ displayData.getScale(),
+ new Quaternionf())
+ );
+
+ // entity shadow
+ display.setShadowRadius(displayData.getShadowRadius());
+ display.setShadowStrength(displayData.getShadowStrength());
+
+ // view range
+ display.setViewRange(displayData.getVisibilityDistance());
+ }
+ }
+
+
+ @Override
+ public void spawnTo(@NotNull final Player player) {
+ if (!new HologramSpawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ if (this.display == null) {
+ create(); // try to create it if it doesn't exist every time
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // could not be created, nothing to show
+ }
+
+ if (!data.getLocation().getWorld().getName().equals(player.getLocation().getWorld().getName())) {
+ return;
+ }
+
+ ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
+
+ // TODO: cache player protocol version
+ // TODO: fix this
+// final var protocolVersion = FancyHologramsPlugin.get().isUsingViaVersion() ? Via.getAPI().getPlayerVersion(player) : MINIMUM_PROTOCOL_VERSION;
+// if (protocolVersion < MINIMUM_PROTOCOL_VERSION) {
+// return false;
+// }
+
+ serverPlayer.connection.send(new ClientboundAddEntityPacket(display));
+ this.viewers.add(player.getUniqueId());
+ updateFor(player);
+
+ }
+
+ @Override
+ public void despawnFrom(@NotNull final Player player) {
+ if (!new HologramDespawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to hide
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundRemoveEntitiesPacket(display.getId()));
+
+ this.viewers.remove(player.getUniqueId());
+ }
+
+
+ @Override
+ public void updateFor(@NotNull final Player player) {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to refresh
+ }
+
+ if (!isViewer(player)) {
+ return;
+ }
+
+ syncWithData();
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundTeleportEntityPacket(display));
+
+ if (display instanceof TextDisplay textDisplay) {
+ textDisplay.setText(PaperAdventure.asVanilla(getShownText(player)));
+ }
+
+ final var values = new ArrayList>();
+
+ //noinspection unchecked
+ for (final var item : ((Int2ObjectMap>) getValue(display.getEntityData(), "e")).values()) {
+ values.add(item.value());
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundSetEntityDataPacket(display.getId(), values));
+ }
+
+}
diff --git a/plugins/fancyholograms/implementation_1_20_1/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_1.java b/plugins/fancyholograms/implementation_1_20_1/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_1.java
new file mode 100644
index 00000000..9fffe629
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_1/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_1.java
@@ -0,0 +1,20 @@
+package de.oliver.fancyholograms.hologram.version;
+
+public enum MappingKeys1_20_1 {
+
+ DATA_INTERPOLATION_DURATION_ID("q"),
+ DATA_INTERPOLATION_START_DELTA_TICKS_ID("p"),
+ DATA_LINE_WIDTH_ID("aM"),
+ DATA_BACKGROUND_COLOR_ID("aN"),
+ ;
+
+ private final String mapping;
+
+ MappingKeys1_20_1(String mapping) {
+ this.mapping = mapping;
+ }
+
+ public String getMapping() {
+ return mapping;
+ }
+}
diff --git a/plugins/fancyholograms/implementation_1_20_2/build.gradle.kts b/plugins/fancyholograms/implementation_1_20_2/build.gradle.kts
new file mode 100644
index 00000000..95da256b
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_2/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ id("java-library")
+ id("io.papermc.paperweight.userdev") version "1.7.7"
+}
+
+
+val minecraftVersion = "1.20.2"
+
+
+dependencies {
+ paperweight.paperDevBundle("$minecraftVersion-R0.1-SNAPSHOT")
+
+ implementation(project(":plugins:fancyholograms:api"))
+ implementation("de.oliver:FancyLib:35")
+ compileOnly("com.viaversion:viaversion-api:5.2.1")
+}
+
+
+tasks {
+ named("assemble") {
+ dependsOn(named("reobfJar"))
+ }
+
+ javadoc {
+ options.encoding = Charsets.UTF_8.name()
+ }
+
+ compileJava {
+ options.encoding = Charsets.UTF_8.name()
+
+ options.release.set(17)
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancyholograms/implementation_1_20_2/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_2.java b/plugins/fancyholograms/implementation_1_20_2/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_2.java
new file mode 100644
index 00000000..e04b5ce5
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_2/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_2.java
@@ -0,0 +1,253 @@
+package de.oliver.fancyholograms.hologram.version;
+
+import com.mojang.math.Transformation;
+import de.oliver.fancyholograms.api.data.*;
+import de.oliver.fancyholograms.api.events.HologramDespawnEvent;
+import de.oliver.fancyholograms.api.events.HologramSpawnEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancylib.ReflectionUtils;
+import io.papermc.paper.adventure.PaperAdventure;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
+import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.SynchedEntityData.DataItem;
+import net.minecraft.network.syncher.SynchedEntityData.DataValue;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.Brightness;
+import net.minecraft.world.entity.Display;
+import net.minecraft.world.entity.Display.TextDisplay;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.Block;
+import org.bukkit.craftbukkit.v1_20_R2.CraftWorld;
+import org.bukkit.craftbukkit.v1_20_R2.entity.CraftPlayer;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaternionf;
+
+import java.util.ArrayList;
+
+import static de.oliver.fancylib.ReflectionUtils.getValue;
+
+public final class Hologram1_20_2 extends Hologram {
+
+ @Nullable
+ private Display display;
+
+ public Hologram1_20_2(@NotNull final HologramData data) {
+ super(data);
+
+ create();
+ }
+
+ public void create() {
+ final var location = data.getLocation();
+ if (location.getWorld() == null) {
+ return; // no location data, cannot be created
+ }
+
+ ServerLevel world = ((CraftWorld) location.getWorld()).getHandle();
+
+ switch (data.getType()) {
+ case TEXT -> this.display = new Display.TextDisplay(EntityType.TEXT_DISPLAY, world);
+ case BLOCK -> this.display = new Display.BlockDisplay(EntityType.BLOCK_DISPLAY, world);
+ case ITEM -> this.display = new Display.ItemDisplay(EntityType.ITEM_DISPLAY, world);
+ }
+ }
+
+ public void syncWithData() {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to update
+ }
+
+ // location data
+ final var location = data.getLocation();
+ if (!location.isWorldLoaded()) {
+ return;
+ } else {
+ display.setPosRaw(location.x(), location.y(), location.z());
+ display.setYRot(location.getYaw());
+ display.setXRot(location.getPitch());
+ }
+
+ if (display instanceof TextDisplay textDisplay && data instanceof TextHologramData textData) {
+ // line width
+ final var DATA_LINE_WIDTH_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_20_2.DATA_LINE_WIDTH_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_LINE_WIDTH_ID, Hologram.LINE_WIDTH);
+
+ // background
+ final var DATA_BACKGROUND_COLOR_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_20_2.DATA_BACKGROUND_COLOR_ID.getMapping()); //DATA_BACKGROUND_COLOR_ID
+
+ final var background = textData.getBackground();
+ if (background == null) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, TextDisplay.INITIAL_BACKGROUND);
+ } else if (background == Hologram.TRANSPARENT) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, 0);
+ } else {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, background.asARGB());
+ }
+
+ // text shadow
+ if (textData.hasTextShadow()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SHADOW));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SHADOW));
+ }
+
+ // text alignment
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.LEFT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_LEFT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_LEFT));
+ }
+
+ // see through
+ if (textData.isSeeThrough()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SEE_THROUGH));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SEE_THROUGH));
+ }
+
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.RIGHT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_RIGHT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_RIGHT));
+ }
+
+ } else if (display instanceof Display.ItemDisplay itemDisplay && data instanceof ItemHologramData itemData) {
+ // item
+ itemDisplay.setItemStack(ItemStack.fromBukkitCopy(itemData.getItemStack()));
+
+ } else if (display instanceof Display.BlockDisplay blockDisplay && data instanceof BlockHologramData blockData) {
+ Block block = BuiltInRegistries.BLOCK.get(ResourceLocation.of("minecraft:" + blockData.getBlock().name().toLowerCase(), ':'));
+ blockDisplay.setBlockState(block.defaultBlockState());
+ }
+
+ if (data instanceof DisplayHologramData displayData) {
+ // interpolation
+ final var DATA_INTERPOLATION_DURATION_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_20_2.DATA_INTERPOLATION_DURATION_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_DURATION_ID, displayData.getInterpolationDuration());
+
+ final var DATA_INTERPOLATION_START_DELTA_TICKS_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_20_2.DATA_INTERPOLATION_START_DELTA_TICKS_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_START_DELTA_TICKS_ID, 0);
+
+ // billboard data
+ display.setBillboardConstraints(switch (displayData.getBillboard()) {
+ case FIXED -> Display.BillboardConstraints.FIXED;
+ case VERTICAL -> Display.BillboardConstraints.VERTICAL;
+ case HORIZONTAL -> Display.BillboardConstraints.HORIZONTAL;
+ case CENTER -> Display.BillboardConstraints.CENTER;
+ });
+
+ // brightness
+ if (displayData.getBrightness() != null) {
+ display.setBrightnessOverride(new Brightness(displayData.getBrightness().getBlockLight(), displayData.getBrightness().getSkyLight()));
+ }
+
+ // entity scale AND MORE!
+ display.setTransformation(new Transformation(
+ displayData.getTranslation(),
+ new Quaternionf(),
+ displayData.getScale(),
+ new Quaternionf())
+ );
+
+ // entity shadow
+ display.setShadowRadius(displayData.getShadowRadius());
+ display.setShadowStrength(displayData.getShadowStrength());
+
+ // view range
+ display.setViewRange(displayData.getVisibilityDistance());
+ }
+ }
+
+
+ @Override
+ public void spawnTo(@NotNull final Player player) {
+ if (!new HologramSpawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ if (this.display == null) {
+ create(); // try to create it if it doesn't exist every time
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // could not be created, nothing to show
+ }
+
+ if (!data.getLocation().getWorld().getName().equals(player.getLocation().getWorld().getName())) {
+ return;
+ }
+
+ ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
+
+ // TODO: cache player protocol version
+ // TODO: fix this
+// final var protocolVersion = FancyHologramsPlugin.get().isUsingViaVersion() ? Via.getAPI().getPlayerVersion(player) : MINIMUM_PROTOCOL_VERSION;
+// if (protocolVersion < MINIMUM_PROTOCOL_VERSION) {
+// return false;
+// }
+
+ serverPlayer.connection.send(new ClientboundAddEntityPacket(display));
+ this.viewers.add(player.getUniqueId());
+ updateFor(player);
+
+ }
+
+ @Override
+ public void despawnFrom(@NotNull final Player player) {
+ if (!new HologramDespawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to hide
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundRemoveEntitiesPacket(display.getId()));
+
+ this.viewers.remove(player.getUniqueId());
+ }
+
+
+ @Override
+ public void updateFor(@NotNull final Player player) {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to refresh
+ }
+
+ if (!isViewer(player)) {
+ return;
+ }
+
+ syncWithData();
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundTeleportEntityPacket(display));
+
+ if (display instanceof TextDisplay textDisplay) {
+ textDisplay.setText(PaperAdventure.asVanilla(getShownText(player)));
+ }
+
+ final var values = new ArrayList>();
+
+ //noinspection unchecked
+ for (final var item : ((Int2ObjectMap>) getValue(display.getEntityData(), "e")).values()) {
+ values.add(item.value());
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundSetEntityDataPacket(display.getId(), values));
+ }
+
+}
diff --git a/plugins/fancyholograms/implementation_1_20_2/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_2.java b/plugins/fancyholograms/implementation_1_20_2/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_2.java
new file mode 100644
index 00000000..59ee90ee
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_2/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_2.java
@@ -0,0 +1,20 @@
+package de.oliver.fancyholograms.hologram.version;
+
+public enum MappingKeys1_20_2 {
+
+ DATA_INTERPOLATION_DURATION_ID("r"),
+ DATA_INTERPOLATION_START_DELTA_TICKS_ID("q"),
+ DATA_LINE_WIDTH_ID("aN"),
+ DATA_BACKGROUND_COLOR_ID("aO"),
+ ;
+
+ private final String mapping;
+
+ MappingKeys1_20_2(String mapping) {
+ this.mapping = mapping;
+ }
+
+ public String getMapping() {
+ return mapping;
+ }
+}
diff --git a/plugins/fancyholograms/implementation_1_20_4/build.gradle.kts b/plugins/fancyholograms/implementation_1_20_4/build.gradle.kts
new file mode 100644
index 00000000..13e190ce
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_4/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ id("java-library")
+ id("io.papermc.paperweight.userdev") version "1.7.7"
+}
+
+
+val minecraftVersion = "1.20.4"
+
+
+dependencies {
+ paperweight.paperDevBundle("$minecraftVersion-R0.1-SNAPSHOT")
+
+ implementation(project(":plugins:fancyholograms:api"))
+ implementation("de.oliver:FancyLib:35")
+ compileOnly("com.viaversion:viaversion-api:5.2.1")
+}
+
+
+tasks {
+ named("assemble") {
+ dependsOn(named("reobfJar"))
+ }
+
+ javadoc {
+ options.encoding = Charsets.UTF_8.name()
+ }
+
+ compileJava {
+ options.encoding = Charsets.UTF_8.name()
+
+ options.release.set(17)
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancyholograms/implementation_1_20_4/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_4.java b/plugins/fancyholograms/implementation_1_20_4/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_4.java
new file mode 100644
index 00000000..1383fac3
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_4/src/main/java/de/oliver/fancyholograms/hologram/version/Hologram1_20_4.java
@@ -0,0 +1,253 @@
+package de.oliver.fancyholograms.hologram.version;
+
+import com.mojang.math.Transformation;
+import de.oliver.fancyholograms.api.data.*;
+import de.oliver.fancyholograms.api.events.HologramDespawnEvent;
+import de.oliver.fancyholograms.api.events.HologramSpawnEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancylib.ReflectionUtils;
+import io.papermc.paper.adventure.PaperAdventure;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
+import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.SynchedEntityData.DataItem;
+import net.minecraft.network.syncher.SynchedEntityData.DataValue;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.Brightness;
+import net.minecraft.world.entity.Display;
+import net.minecraft.world.entity.Display.TextDisplay;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.Block;
+import org.bukkit.craftbukkit.v1_20_R3.CraftWorld;
+import org.bukkit.craftbukkit.v1_20_R3.entity.CraftPlayer;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaternionf;
+
+import java.util.ArrayList;
+
+import static de.oliver.fancylib.ReflectionUtils.getValue;
+
+public final class Hologram1_20_4 extends Hologram {
+
+ @Nullable
+ private Display display;
+
+ public Hologram1_20_4(@NotNull final HologramData data) {
+ super(data);
+
+ create();
+ }
+
+ public void create() {
+ final var location = data.getLocation();
+ if (location.getWorld() == null) {
+ return; // no location data, cannot be created
+ }
+
+ ServerLevel world = ((CraftWorld) location.getWorld()).getHandle();
+
+ switch (data.getType()) {
+ case TEXT -> this.display = new Display.TextDisplay(EntityType.TEXT_DISPLAY, world);
+ case BLOCK -> this.display = new Display.BlockDisplay(EntityType.BLOCK_DISPLAY, world);
+ case ITEM -> this.display = new Display.ItemDisplay(EntityType.ITEM_DISPLAY, world);
+ }
+ }
+
+ public void syncWithData() {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to update
+ }
+
+ // location data
+ final var location = data.getLocation();
+ if (!location.isWorldLoaded()) {
+ return;
+ } else {
+ display.setPosRaw(location.x(), location.y(), location.z());
+ display.setYRot(location.getYaw());
+ display.setXRot(location.getPitch());
+ }
+
+ if (display instanceof TextDisplay textDisplay && data instanceof TextHologramData textData) {
+ // line width
+ final var DATA_LINE_WIDTH_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_20_4.TEXT_DISPLAY__DATA_LINE_WIDTH_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_LINE_WIDTH_ID, Hologram.LINE_WIDTH);
+
+ // background
+ final var DATA_BACKGROUND_COLOR_ID = ReflectionUtils.getStaticValue(TextDisplay.class, MappingKeys1_20_4.TEXT_DISPLAY__DATA_BACKGROUND_COLOR_ID.getMapping()); //DATA_BACKGROUND_COLOR_ID
+
+ final var background = textData.getBackground();
+ if (background == null) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, TextDisplay.INITIAL_BACKGROUND);
+ } else if (background == Hologram.TRANSPARENT) {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, 0);
+ } else {
+ display.getEntityData().set((EntityDataAccessor) DATA_BACKGROUND_COLOR_ID, background.asARGB());
+ }
+
+ // text shadow
+ if (textData.hasTextShadow()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SHADOW));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SHADOW));
+ }
+
+ // text alignment
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.LEFT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_LEFT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_LEFT));
+ }
+
+ // see through
+ if (textData.isSeeThrough()) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_SEE_THROUGH));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_SEE_THROUGH));
+ }
+
+ if (textData.getTextAlignment() == org.bukkit.entity.TextDisplay.TextAlignment.RIGHT) {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() | TextDisplay.FLAG_ALIGN_RIGHT));
+ } else {
+ textDisplay.setFlags((byte) (textDisplay.getFlags() & ~TextDisplay.FLAG_ALIGN_RIGHT));
+ }
+
+ } else if (display instanceof Display.ItemDisplay itemDisplay && data instanceof ItemHologramData itemData) {
+ // item
+ itemDisplay.setItemStack(ItemStack.fromBukkitCopy(itemData.getItemStack()));
+
+ } else if (display instanceof Display.BlockDisplay blockDisplay && data instanceof BlockHologramData blockData) {
+ Block block = BuiltInRegistries.BLOCK.get(ResourceLocation.of("minecraft:" + blockData.getBlock().name().toLowerCase(), ':'));
+ blockDisplay.setBlockState(block.defaultBlockState());
+ }
+
+ if (data instanceof DisplayHologramData displayData) {
+ // interpolation
+ final var DATA_INTERPOLATION_DURATION_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_20_4.DISPLAY__DATA_TRANSFORMATION_INTERPOLATION_DURATION_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_DURATION_ID, displayData.getInterpolationDuration());
+
+ final var DATA_INTERPOLATION_START_DELTA_TICKS_ID = ReflectionUtils.getStaticValue(Display.class, MappingKeys1_20_4.DISPLAY__DATA_TRANSFORMATION_INTERPOLATION_START_DELTA_TICKS_ID.getMapping());
+ display.getEntityData().set((EntityDataAccessor) DATA_INTERPOLATION_START_DELTA_TICKS_ID, 0);
+
+ // billboard data
+ display.setBillboardConstraints(switch (displayData.getBillboard()) {
+ case FIXED -> Display.BillboardConstraints.FIXED;
+ case VERTICAL -> Display.BillboardConstraints.VERTICAL;
+ case HORIZONTAL -> Display.BillboardConstraints.HORIZONTAL;
+ case CENTER -> Display.BillboardConstraints.CENTER;
+ });
+
+ // brightness
+ if (displayData.getBrightness() != null) {
+ display.setBrightnessOverride(new Brightness(displayData.getBrightness().getBlockLight(), displayData.getBrightness().getSkyLight()));
+ }
+
+ // entity scale AND MORE!
+ display.setTransformation(new Transformation(
+ displayData.getTranslation(),
+ new Quaternionf(),
+ displayData.getScale(),
+ new Quaternionf())
+ );
+
+ // entity shadow
+ display.setShadowRadius(displayData.getShadowRadius());
+ display.setShadowStrength(displayData.getShadowStrength());
+
+ // view range
+ display.setViewRange(displayData.getVisibilityDistance());
+ }
+ }
+
+
+ @Override
+ public void spawnTo(@NotNull final Player player) {
+ if (!new HologramSpawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ if (this.display == null) {
+ create(); // try to create it if it doesn't exist every time
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // could not be created, nothing to show
+ }
+
+ if (!data.getLocation().getWorld().getName().equals(player.getLocation().getWorld().getName())) {
+ return;
+ }
+
+ ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
+
+ // TODO: cache player protocol version
+ // TODO: fix this
+// final var protocolVersion = FancyHologramsPlugin.get().isUsingViaVersion() ? Via.getAPI().getPlayerVersion(player) : MINIMUM_PROTOCOL_VERSION;
+// if (protocolVersion < MINIMUM_PROTOCOL_VERSION) {
+// return false;
+// }
+
+ serverPlayer.connection.send(new ClientboundAddEntityPacket(display));
+ this.viewers.add(player.getUniqueId());
+ updateFor(player);
+
+ }
+
+ @Override
+ public void despawnFrom(@NotNull final Player player) {
+ if (!new HologramDespawnEvent(this, player).callEvent()) {
+ return;
+ }
+
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to hide
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundRemoveEntitiesPacket(display.getId()));
+
+ this.viewers.remove(player.getUniqueId());
+ }
+
+
+ @Override
+ public void updateFor(@NotNull final Player player) {
+ final var display = this.display;
+ if (display == null) {
+ return; // doesn't exist, nothing to refresh
+ }
+
+ if (!isViewer(player)) {
+ return;
+ }
+
+ syncWithData();
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundTeleportEntityPacket(display));
+
+ if (display instanceof TextDisplay textDisplay) {
+ textDisplay.setText(PaperAdventure.asVanilla(getShownText(player)));
+ }
+
+ final var values = new ArrayList>();
+
+ //noinspection unchecked
+ for (final var item : ((Int2ObjectMap>) getValue(display.getEntityData(), "e")).values()) {
+ values.add(item.value());
+ }
+
+ ((CraftPlayer) player).getHandle().connection.send(new ClientboundSetEntityDataPacket(display.getId(), values));
+ }
+
+}
diff --git a/plugins/fancyholograms/implementation_1_20_4/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_4.java b/plugins/fancyholograms/implementation_1_20_4/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_4.java
new file mode 100644
index 00000000..f9736289
--- /dev/null
+++ b/plugins/fancyholograms/implementation_1_20_4/src/main/java/de/oliver/fancyholograms/hologram/version/MappingKeys1_20_4.java
@@ -0,0 +1,20 @@
+package de.oliver.fancyholograms.hologram.version;
+
+public enum MappingKeys1_20_4 {
+
+ DISPLAY__DATA_TRANSFORMATION_INTERPOLATION_DURATION_ID("r"),
+ DISPLAY__DATA_TRANSFORMATION_INTERPOLATION_START_DELTA_TICKS_ID("q"),
+ TEXT_DISPLAY__DATA_LINE_WIDTH_ID("aN"),
+ TEXT_DISPLAY__DATA_BACKGROUND_COLOR_ID("aO"),
+ ;
+
+ private final String mapping;
+
+ MappingKeys1_20_4(String mapping) {
+ this.mapping = mapping;
+ }
+
+ public String getMapping() {
+ return mapping;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/FancyHologramsCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/FancyHologramsCMD.java
new file mode 100644
index 00000000..e82cc12e
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/FancyHologramsCMD.java
@@ -0,0 +1,146 @@
+package de.oliver.fancyholograms.commands;
+
+import de.oliver.fancyholograms.api.data.HologramData;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.converter.ConverterTarget;
+import de.oliver.fancyholograms.converter.FHConversionRegistry;
+import de.oliver.fancyholograms.converter.HologramConversionSession;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+
+public final class FancyHologramsCMD extends Command {
+
+ public static final String FH_COMMAND_USAGE = "/fancyholograms ";
+
+ @NotNull
+ private final FancyHologramsPlugin plugin;
+
+ public FancyHologramsCMD(@NotNull final FancyHologramsPlugin plugin) {
+ super("fancyholograms");
+ setPermission("fancyholograms.admin");
+ this.plugin = plugin;
+ }
+
+ @Override
+ public boolean execute(@NotNull CommandSender sender, @NotNull String label, @NotNull String[] args) {
+ if (!testPermission(sender)) {
+ return false;
+ }
+
+ if (args.length < 1) {
+ MessageHelper.info(sender, FH_COMMAND_USAGE);
+ return false;
+ }
+
+ switch (args[0].toLowerCase(Locale.ROOT)) {
+ case "save" -> {
+ this.plugin.savePersistentHolograms();
+ MessageHelper.success(sender, "Saved all holograms");
+ }
+ case "reload" -> {
+ this.plugin.getHologramConfiguration().reload(plugin);
+
+ this.plugin.getRegistry().clear();
+ for (World world : Bukkit.getWorlds()) {
+ Collection hologramData = this.plugin.getStorage().loadAll(world.getName());
+ for (HologramData data : hologramData) {
+ Hologram hologram = this.plugin.getHologramFactory().apply(data);
+ this.plugin.getRegistry().register(hologram);
+ }
+ }
+
+ MessageHelper.success(sender, "Reloaded config and holograms");
+ }
+ case "version" -> {
+ FancyHologramsPlugin.get().getHologramThread().submit(() -> {
+ FancyHologramsPlugin.get().getVersionConfig().checkVersionAndDisplay(sender, false);
+ });
+ }
+ case "convert" -> {
+ if (args.length < 3) {
+ MessageHelper.info(sender, "Usage: /fancyholograms convert [args...]");
+ return false;
+ }
+
+ final String converterId = args[1];
+ FHConversionRegistry.getConverterById(converterId)
+ .ifPresentOrElse((converter) -> {
+ final String[] converterArgs = Arrays.asList(args)
+ .subList(2, args.length)
+ .toArray(String[]::new);
+
+ final ConverterTarget target = ConverterTarget.ofStringNullable(args[2]);
+
+ if (target == null) {
+ MessageHelper.error(sender, "Invalid regex for your conversion target!");
+ return;
+ }
+
+ final HologramConversionSession session = new HologramConversionSession(target, sender, converterArgs);
+
+ try {
+ final List holograms = converter.convert(session);
+
+ for (final HologramData data : holograms) {
+ final Hologram hologram = this.plugin.getHologramFactory().apply(data);
+ this.plugin.getRegistry().register(hologram);
+ }
+
+ this.plugin.savePersistentHolograms();
+ // TODO(matt): Give options to delete them or teleport and a list of IDs please
+
+ MessageHelper.success(sender, String.format("Converted successfully, produced %s total holograms!", holograms.size()));
+ } catch (Exception error) {
+ MessageHelper.error(sender, error.getMessage());
+ }
+ }, () -> MessageHelper.error(sender, "That converter is not registered. Look at the developer documentation if you are adding converters."));
+ }
+ default -> {
+ MessageHelper.info(sender, FH_COMMAND_USAGE);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public @NotNull List tabComplete(@NotNull CommandSender sender, @NotNull String label, @NotNull String[] args) throws IllegalArgumentException {
+ if (args.length < 1) {
+ return Collections.emptyList();
+ }
+
+ List suggestions = new ArrayList<>();
+
+ if (args.length == 1) {
+ suggestions.addAll(Arrays.asList("version", "reload", "save", "convert"));
+ } else {
+ if (Objects.equals(args[0], "convert")) {
+
+ if (args.length == 2) {
+ suggestions.addAll(FHConversionRegistry.getAllUsableConverterIds());
+ } else if (args.length == 3) {
+ final String converterId = args[1];
+ FHConversionRegistry.getConverterById(converterId)
+ .ifPresent((converter) -> {
+ suggestions.addAll(converter.getConvertableHolograms());
+ suggestions.add("*");
+ });
+ }
+ }
+ }
+
+ String lastArgument = args[args.length - 1];
+
+ return suggestions.stream()
+ .filter(alias -> alias.startsWith(lastArgument.toLowerCase(Locale.ROOT)))
+ .toList();
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/FancyHologramsTestCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/FancyHologramsTestCMD.java
new file mode 100644
index 00000000..7deca077
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/FancyHologramsTestCMD.java
@@ -0,0 +1,114 @@
+package de.oliver.fancyholograms.commands;
+
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.tests.FHTests;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collections;
+import java.util.List;
+
+public class FancyHologramsTestCMD extends Command {
+
+ @NotNull
+ private final FancyHologramsPlugin plugin;
+
+ public FancyHologramsTestCMD(@NotNull final FancyHologramsPlugin plugin) {
+ super("FancyHologramsTest");
+ setPermission("fancyholograms.admin");
+ this.plugin = plugin;
+ }
+
+ @Override
+ public @NotNull List tabComplete(@NotNull CommandSender sender, @NotNull String alias, @NotNull String[] args) throws IllegalArgumentException {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean execute(@NotNull CommandSender commandSender, @NotNull String s, @NotNull String[] strings) {
+ Player p = (Player) commandSender;
+
+ FHTests tests = new FHTests();
+ if(tests.runAllTests(p)) {
+ MessageHelper.success(p, "All tests have been successfully run!");
+ } else {
+ MessageHelper.error(p, "There was an issue running the tests!");
+ }
+
+ return true;
+ }
+
+ // @Override
+// public @NotNull List tabComplete(@NotNull CommandSender sender, @NotNull String alias, @NotNull String[] args) throws IllegalArgumentException {
+// if (args.length == 1) {
+// return Arrays.asList("spawn100", "test1");
+// }
+//
+// return Collections.emptyList();
+// }
+
+// @Override
+// public boolean execute(@NotNull CommandSender sender, @NotNull String label, @NotNull String[] args) {
+// if (!testPermission(sender)) {
+// return false;
+// }
+//
+// if (!(sender instanceof Player p)) {
+// MessageHelper.error(sender, "Only players can use this command!");
+// return false;
+// }
+//
+// if (args.length == 1 && args[0].equalsIgnoreCase("spawn100")) {
+// for (int i = 0; i < 10; i++) {
+// for (int j = 0; j < 10; j++) {
+// int n = (i * 10 + j) + 1;
+// TextHologramData textData = new TextHologramData("holo-" + n, p.getLocation().clone().add(5 * i + 1, 0, 5 * j + 1));
+// textData.setText(Arrays.asList(
+// "This is a test hologram! (#" + n + ")",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris."
+// ));
+// textData.setTextUpdateInterval(100)
+// .setScale(new Vector3f(.5f, .5f, .5f))
+// .setVisibilityDistance(100);
+//
+// Hologram hologram = this.plugin.getHologramFactory().apply(textData);
+// hologram.spawnTo(p);
+// }
+// }
+//
+// return true;
+// } else if (args.length == 1 && args[0].equalsIgnoreCase("test1")) {
+// TextHologramData textData = new TextHologramData("holo-test1", p.getLocation());
+// textData.setText(Arrays.asList(
+// "This is a test hologram!",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.",
+// "Lorem ipsum dolor sit amet, consec tetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris."
+// ))
+// .setTextUpdateInterval(100)
+// .setTextAlignment(TextDisplay.TextAlignment.CENTER)
+// .setBackground(Color.fromARGB(15, 78, 237, 176))
+// .setTextShadow(true)
+// .setScale(new Vector3f(2, 2, 2))
+// .setBillboard(Display.Billboard.CENTER)
+// .setBrightness(new Display.Brightness(15, 15))
+// .setShadowRadius(3)
+// .setShadowStrength(3)
+// .setVisibilityDistance(100);
+//
+// Hologram hologram = this.plugin.getHologramFactory().apply(textData);
+// hologram.spawnTo(p);
+// }
+//
+// return false;
+// }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/HologramCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/HologramCMD.java
new file mode 100644
index 00000000..075a3af9
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/HologramCMD.java
@@ -0,0 +1,388 @@
+package de.oliver.fancyholograms.commands;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.data.HologramData;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.api.hologram.HologramType;
+import de.oliver.fancyholograms.commands.hologram.*;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.PluginUtils;
+import de.oliver.fancylib.MessageHelper;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import net.kyori.adventure.text.format.NamedTextColor;
+import org.bukkit.Material;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Display;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.TextDisplay;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+public final class HologramCMD extends Command {
+
+ private static final String HELP_TEXT = """
+ <%primary_color%>FancyHolograms commands help:
+ <%primary_color%>- /hologram help - Shows all (sub)commands
+ <%primary_color%>- /hologram list - Shows you a overview of all holograms
+ <%primary_color%>- /hologram nearby - Shows all holograms nearby you in a range
+ <%primary_color%>- /hologram teleport - Teleports you to a hologram
+ <%primary_color%>- /hologram create - Creates a new hologram
+ <%primary_color%>- /hologram remove - Removes a hologram
+ <%primary_color%>- /hologram copy - Copies a hologram
+ <%primary_color%>- /hologram edit addLine - Adds a line at the bottom
+ <%primary_color%>- /hologram edit removeLine - Removes a line at the bottom
+ <%primary_color%>- /hologram edit insertBefore - Inserts a line before another
+ <%primary_color%>- /hologram edit insertAfter - Inserts a line after another
+ <%primary_color%>- /hologram edit setLine - Edits the line
+ <%primary_color%>- /hologram edit position - Teleports the hologram to you
+ <%primary_color%>- /hologram edit moveTo [yaw] [pitch] - Teleports the hologram to the coordinates
+ <%primary_color%>- /hologram edit rotate - Rotates the hologram
+ <%primary_color%>- /hologram edit scale - Changes the scale of the hologram
+ <%primary_color%>- /hologram edit billboard - Changes the billboard of the hologram
+ <%primary_color%>- /hologram edit background - Changes the background of the hologram
+ <%primary_color%>- /hologram edit textShadow - Enables/disables the text shadow
+ <%primary_color%>- /hologram edit textAlignment - Sets the text alignment
+ <%primary_color%>- /hologram edit seeThrough - Enables/disables whether the text can be seen through blocks
+ <%primary_color%>- /hologram edit shadowRadius - Changes the shadow radius of the hologram
+ <%primary_color%>- /hologram edit shadowStrength - Changes the shadow strength of the hologram
+ <%primary_color%>- /hologram edit brightness <0-15> - Changes the brightness of the hologram
+ <%primary_color%>- /hologram edit updateTextInterval - Sets the interval for updating the text
+ """.replace("%primary_color%", MessageHelper.getPrimaryColor());
+
+ private static final String HELP_TEXT_NPCS = """
+ <%primary_color%>- /hologram edit linkWithNpc - Links the hologram with an NPC
+ <%primary_color%>- /hologram edit unlinkWithNpc - Unlinks the hologram with an NPC
+ """.replace("%primary_color%", MessageHelper.getPrimaryColor());
+
+ @NotNull
+ private final FancyHologramsPlugin plugin;
+
+ public HologramCMD(@NotNull final FancyHologramsPlugin plugin) {
+ super("hologram", "Main command for the FancyHolograms plugin", "/hologram help", List.of("holograms", "holo", "fholo"));
+
+ setPermission("fancyholograms.admin");
+
+ this.plugin = plugin;
+ }
+
+ public static boolean callModificationEvent(@NotNull final Hologram hologram, @NotNull final CommandSender player, @NotNull final HologramData updatedData, @NotNull final HologramUpdateEvent.HologramModification modification) {
+ final var result = new HologramUpdateEvent(hologram, player, updatedData, modification).callEvent();
+
+ if (!result) {
+ MessageHelper.error(player, "Cancelled hologram modification");
+ }
+
+ return result;
+ }
+
+ @Override
+ public boolean execute(@NotNull CommandSender sender, @NotNull String label, @NotNull String[] args) {
+ if (!testPermission(sender)) {
+ return false;
+ }
+
+ if (args.length == 0 || args[0].equalsIgnoreCase("help")) {
+ MessageHelper.info(sender, HELP_TEXT + (!PluginUtils.isFancyNpcsEnabled() ? "" : "\n" + HELP_TEXT_NPCS));
+ return false;
+ }
+
+
+ if (args[0].equalsIgnoreCase("list")) {
+ return new ListCMD().run(sender, null, args);
+ }
+
+
+ if (args.length < 2) {
+ MessageHelper.error(sender, "Wrong usage: /hologram help");
+ return false;
+ }
+
+
+ if (args[0].equalsIgnoreCase("create")) {
+ return new CreateCMD().run(sender, null, args);
+ }
+
+ if (args[0].equalsIgnoreCase("nearby")) {
+ return new NearbyCMD().run(sender, null, args);
+ }
+
+ final var hologram = this.plugin.getRegistry().get(args[1]).orElse(null);
+ if (hologram == null) {
+ MessageHelper.error(sender, "Could not find hologram: '" + args[1] + "'");
+ return false;
+ }
+
+
+ return switch (args[0].toLowerCase(Locale.ROOT)) {
+ case "info" -> new InfoCMD().run(sender, hologram, args);
+ case "remove" -> new RemoveCMD().run(sender, hologram, args);
+ case "teleport" -> new TeleportCMD().run(sender, hologram, args);
+ case "copy" -> new CopyCMD().run(sender, hologram, args);
+ case "edit" -> {
+ if (args.length < 3) {
+ MessageHelper.error(sender, "Wrong usage: /hologram help");
+ yield false;
+ }
+
+ final var updated = edit(sender, hologram, args);
+
+ if (updated) {
+ if (sender instanceof Player p) {
+ plugin.getController().refreshHologram(hologram, p);
+ }
+
+ //TODO: idk
+ // hologram.queueUpdate();
+ }
+
+ yield updated;
+ }
+ default -> false;
+ };
+ }
+
+ @Override
+ public @NotNull List tabComplete(@NotNull CommandSender sender, @NotNull String label, @NotNull String[] args) throws IllegalArgumentException {
+ if (args.length == 0) {
+ return Collections.emptyList();
+ }
+
+ // /holo {tab:action}
+ if (args.length == 1) {
+ return Stream.of("help", "list", "teleport", "create", "remove", "edit", "copy", "info", "nearby").filter(input -> input.startsWith(args[0].toLowerCase(Locale.ROOT))).toList();
+ }
+
+ // /holo create {tab:type}
+ if (args.length == 2 && args[0].equalsIgnoreCase("create")) {
+ return Arrays.asList("text", "item", "block");
+ }
+
+ // /holo [action] {tab:hologram}
+ if (args.length == 2) {
+ final var action = args[0].toLowerCase(Locale.ROOT);
+
+ if (!Set.of("teleport", "remove", "edit", "copy", "info").contains(action)) {
+ return Collections.emptyList();
+ }
+
+ return this.plugin.getRegistry().getAllPersistent()
+ .stream()
+ .map(hologram -> hologram.getData().getName())
+ .filter(input -> input.toLowerCase().startsWith(args[1].toLowerCase(Locale.ROOT)))
+ .toList();
+ }
+
+ final var hologram = this.plugin.getRegistry().get(args[1]).orElse(null);
+ if (hologram == null) {
+ return Collections.emptyList();
+ }
+
+ HologramType type = hologram.getData().getType();
+
+ // /holo edit [hologram] {tab:option}
+ if (args.length == 3) {
+ if (!args[0].equalsIgnoreCase("edit")) {
+ return Collections.emptyList();
+ }
+
+ final var usingNpcs = PluginUtils.isFancyNpcsEnabled();
+
+ List suggestions = new ArrayList<>(Arrays.asList("position", "moveHere", "center", "moveTo", "rotate", "rotatepitch", "billboard", "scale", "translate", "visibilityDistance", "visibility", "shadowRadius", "shadowStrength", "brightness", usingNpcs ? "linkWithNpc" : "", usingNpcs ? "unlinkWithNpc" : ""));
+ suggestions.addAll(type.getCommands());
+
+ return suggestions.stream().filter(input -> input.toLowerCase().startsWith(args[2].toLowerCase(Locale.ROOT))).toList();
+ }
+
+ if (!args[0].equalsIgnoreCase("edit")) {
+ return Collections.emptyList();
+ }
+
+ // /holo edit [hologram] [option] {tab:contextual}
+ if (args.length == 4) {
+ final var suggestions = switch (args[2].toLowerCase(Locale.ROOT)) {
+ case "billboard" -> {
+ final var values = new ArrayList<>(List.of(Display.Billboard.values()));
+
+ if (hologram.getData() instanceof DisplayHologramData displayData) {
+ values.remove(displayData.getBillboard());
+ }
+
+ yield values.stream().map(Enum::name);
+ }
+ case "background" -> {
+ TextHologramData textData = (TextHologramData) hologram.getData();
+ final var colors = new ArrayList<>(NamedTextColor.NAMES.keys());
+
+ colors.add("reset");
+ colors.add("default");
+ colors.add("transparent");
+
+ final var current = textData.getBackground();
+
+ if (current == null) {
+ colors.remove("reset");
+ colors.remove("default");
+ } else if (current == Hologram.TRANSPARENT) {
+ colors.remove("transparent");
+ } else {
+ NamedTextColor named = current.getAlpha() == 255 ? NamedTextColor.namedColor(current.asRGB()) : null;
+ colors.add(named != null ? named.toString() : '#' + Integer.toHexString(current.asARGB()));
+ }
+
+ yield colors.stream();
+ }
+ case "textshadow" -> {
+ TextHologramData textData = (TextHologramData) hologram.getData();
+ yield Stream.of(!textData.hasTextShadow()).map(Object::toString);
+ }
+ case "brightness" -> Stream.of("block", "sky");
+ case "textalignment" -> Arrays.stream(TextDisplay.TextAlignment.values()).map(Enum::name);
+ case "setline", "removeline" -> {
+ TextHologramData textData = (TextHologramData) hologram.getData();
+ yield IntStream.range(1, textData.getText().size() + 1).mapToObj(Integer::toString);
+ }
+ case "linkwithnpc" -> {
+ if (!PluginUtils.isFancyNpcsEnabled()) {
+ yield Stream.empty();
+ }
+
+ yield FancyNpcsPlugin.get().getNpcManager().getAllNpcs().stream().map(npc -> npc.getData().getName());
+ }
+ case "block" -> Arrays.stream(Material.values()).filter(Material::isBlock).map(Enum::name);
+ case "seethrough" -> Stream.of("true", "false");
+ case "visibility" -> new VisibilityCMD().tabcompletion(sender, hologram, args).stream();
+
+ default -> null;
+ };
+
+ if (suggestions != null) {
+ return suggestions.filter(input -> input.toLowerCase().startsWith(args[3].toLowerCase(Locale.ROOT))).toList();
+ }
+ }
+
+ // /holo edit [hologram] setline [number] {tab:line_text}
+ if (args[2].equalsIgnoreCase("setline")) {
+ TextHologramData textData = (TextHologramData) hologram.getData();
+
+ final var index = Ints.tryParse(args[3]);
+ if (index == null || index < 1 || index > textData.getText().size()) {
+ return Collections.emptyList();
+ }
+
+ return List.of(textData.getText().get(index - 1));
+ }
+
+ // /holo edit [hologram] moveto {tab:x} {tab:y} {tab:z}
+ if (args[2].equalsIgnoreCase("moveto")) {
+ if (!(sender instanceof Player player)) {
+ return Collections.emptyList();
+ }
+
+ final var suggestions = new ArrayList();
+ suggestions.add("~");
+ suggestions.add("~~");
+
+ if (args.length == 7) {
+ suggestions.add(String.valueOf(player.getLocation().getYaw()));
+ }
+
+ if (args.length == 8) {
+ suggestions.add(String.valueOf(player.getLocation().getPitch()));
+ }
+
+ final var target = player.getTargetBlockExact(10);
+ if (target != null) {
+ final var coordinate = switch (args.length) {
+ case 4 -> target.getX();
+ case 5 -> target.getY();
+ case 6 -> target.getZ();
+ default -> null;
+ };
+
+ suggestions.add(String.valueOf(coordinate));
+ }
+
+ return suggestions;
+ }
+
+ if(args[2].equalsIgnoreCase("brightness")) {
+ if(args.length == 4) {
+ return List.of("block", "sky");
+ }
+
+ if(args.length > 5) {
+ return Collections.emptyList();
+ }
+
+ return List.of("0", "5", "10", "15");
+ }
+
+ return Collections.emptyList();
+ }
+
+ private boolean edit(@NotNull final CommandSender player, @NotNull final Hologram hologram, @NotNull final String[] args) {
+ final var action = args[2].toLowerCase();
+
+ // actions without a data
+ switch (action) {
+ case "position", "movehere" -> {
+ return new MoveHereCMD().run(player, hologram, args);
+ }
+ case "center" -> {
+ return new CenterCMD().run(player, hologram, args);
+ }
+ case "unlinkwithnpc" -> {
+ return new UnlinkWithNpcCMD().run(player, hologram, args);
+ }
+ case "item" -> {
+ return new ItemCMD().run(player, hologram, args);
+ }
+ }
+
+ if (args.length == 3) {
+ MessageHelper.error(player, "Wrong usage: /hologram help");
+ return false;
+ }
+
+ return switch (action) {
+ // display data
+ case "moveto" -> new MoveToCMD().run(player, hologram, args);
+ case "rotate" -> new RotateCMD().run(player, hologram, args);
+ case "rotatepitch" -> new RotatePitchCMD().run(player, hologram, args);
+ case "billboard" -> new BillboardCMD().run(player, hologram, args);
+ case "scale" -> new ScaleCMD().run(player, hologram, args);
+ case "translate" -> new TranslateCommand().run(player, hologram, args);
+ case "updatetextinterval" -> new UpdateTextIntervalCMD().run(player, hologram, args);
+ case "visibilitydistance" -> new VisibilityDistanceCMD().run(player, hologram, args);
+ case "visibility" -> new VisibilityCMD().run(player, hologram, args);
+ case "linkwithnpc" -> new LinkWithNpcCMD().run(player, hologram, args);
+ case "shadowradius" -> new ShadowRadiusCMD().run(player, hologram, args);
+ case "shadowstrength" -> new ShadowStrengthCMD().run(player, hologram, args);
+ case "brightness" -> new BrightnessCMD().run(player, hologram, args);
+
+ // text data
+ case "background" -> new BackgroundCMD().run(player, hologram, args);
+ case "addline" -> new AddLineCMD().run(player, hologram, args);
+ case "setline" -> new SetLineCMD().run(player, hologram, args);
+ case "removeline" -> new RemoveLineCMD().run(player, hologram, args);
+ case "insertbefore" -> new InsertBeforeCMD().run(player, hologram, args);
+ case "insertafter" -> new InsertAfterCMD().run(player, hologram, args);
+ case "textshadow" -> new TextShadowCMD().run(player, hologram, args);
+ case "textalignment" -> new TextAlignmentCMD().run(player, hologram, args);
+ case "seethrough" -> new SeeThroughCMD().run(player, hologram, args);
+
+ // block data
+ case "block" -> new BlockCMD().run(player, hologram, args);
+
+ default -> false;
+ };
+ }
+
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/Subcommand.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/Subcommand.java
new file mode 100644
index 00000000..edd4985d
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/Subcommand.java
@@ -0,0 +1,16 @@
+package de.oliver.fancyholograms.commands;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public interface Subcommand {
+
+ List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args);
+
+ boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args);
+
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/AddLineCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/AddLineCMD.java
new file mode 100644
index 00000000..4563b8d6
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/AddLineCMD.java
@@ -0,0 +1,40 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class AddLineCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ if (!(player.hasPermission("fancyholograms.hologram.edit.line.add"))) {
+ MessageHelper.error(player, "You don't have the required permission to add a line to this hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ String text = "";
+ for (int i = 3; i < args.length; i++) {
+ text += args[i] + " ";
+ }
+ text = text.substring(0, text.length() - 1);
+
+ return SetLineCMD.setLine(player, hologram, Integer.MAX_VALUE, text);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BackgroundCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BackgroundCMD.java
new file mode 100644
index 00000000..ff12f004
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BackgroundCMD.java
@@ -0,0 +1,91 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import net.kyori.adventure.text.format.NamedTextColor;
+import org.bukkit.Color;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+public class BackgroundCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ if (!(player.hasPermission("fancyholograms.hologram.edit.background"))) {
+ MessageHelper.error(player, "You don't have the required permission to chnage the background of a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+
+ final var color = args[3].toLowerCase(Locale.ROOT);
+
+ final Color background;
+
+ if (color.equals("reset") || color.equals("default")) {
+ background = null;
+ } else {
+ if (color.equals("transparent")) {
+ background = Hologram.TRANSPARENT;
+ } else if (color.startsWith("#")) {
+ Color parsed = Color.fromARGB((int) Long.parseLong(color.substring(1), 16));
+ //make background solid color if RGB hex provided
+ if (color.length() == 7) background = parsed.setAlpha(255);
+ else background = parsed;
+ } else {
+ NamedTextColor named = NamedTextColor.NAMES.value(color.replace(' ', '_'));
+ background = named == null ? null : Color.fromARGB(named.value() | 0xC8000000);
+ }
+
+ if (background == null) {
+ MessageHelper.error(player, "Could not parse background color");
+ return false;
+ }
+ }
+
+ if (Objects.equals(background, textData.getBackground())) {
+ MessageHelper.warning(player, "This hologram already has this background color");
+ return false;
+ }
+
+ final var copied = textData.copy(textData.getName());
+ copied.setBackground(background);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.BACKGROUND)) {
+ return false;
+ }
+
+ if (Objects.equals(copied.getBackground(), textData.getBackground())) {
+ MessageHelper.warning(player, "This hologram already has this background color");
+ return false;
+ }
+
+ textData.setBackground(copied.getBackground());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed background color");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BillboardCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BillboardCMD.java
new file mode 100644
index 00000000..6e2ae09b
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BillboardCMD.java
@@ -0,0 +1,73 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.base.Enums;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.apache.commons.lang3.StringUtils;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Display;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+
+public class BillboardCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.billboard"))) {
+ MessageHelper.error(player, "You don't have the required permission to change the billboard of a hologram");
+ return false;
+ }
+
+ final var billboard = Enums.getIfPresent(Display.Billboard.class, args[3].toUpperCase(Locale.ROOT)).orNull();
+
+ if (billboard == null) {
+ MessageHelper.error(player, "Could not parse billboard");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof DisplayHologramData displayData)) {
+ MessageHelper.error(player, "This command can only be used on display holograms");
+ return false;
+ }
+
+ if (billboard == displayData.getBillboard()) {
+ MessageHelper.warning(player, "This billboard is already set");
+ return false;
+ }
+
+ final var copied = displayData.copy(displayData.getName());
+ copied.setBillboard(billboard);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.BILLBOARD)) {
+ return false;
+ }
+
+ if (copied.getBillboard() == displayData.getBillboard()) {
+ MessageHelper.warning(player, "This billboard is already set");
+ return false;
+ }
+
+ displayData.setBillboard(copied.getBillboard());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed the billboard to " + StringUtils.capitalize(billboard.name().toLowerCase(Locale.ROOT)));
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BlockCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BlockCMD.java
new file mode 100644
index 00000000..02cba224
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BlockCMD.java
@@ -0,0 +1,68 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.BlockHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Material;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class BlockCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.block"))) {
+ MessageHelper.error(player, "You don't have the required permission to change the block of this hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof BlockHologramData blockData)) {
+ MessageHelper.error(player, "This command can only be used on item holograms");
+ return false;
+ }
+ Material block = Material.getMaterial(args[3]);
+ if (block == null) {
+ MessageHelper.error(player, "Could not find block type");
+ return false;
+ }
+
+ if (block == blockData.getBlock()) {
+ MessageHelper.warning(player, "This block is already set");
+ return false;
+ }
+
+ final var copied = blockData.copy(blockData.getName());
+ copied.setBlock(block);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.BILLBOARD)) {
+ return false;
+ }
+
+ if (copied.getBlock() == blockData.getBlock()) {
+ MessageHelper.warning(player, "This block is already set");
+ return false;
+ }
+
+ blockData.setBlock(block);
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Set block to '" + block.name() + "'");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BrightnessCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BrightnessCMD.java
new file mode 100644
index 00000000..61cbdad9
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/BrightnessCMD.java
@@ -0,0 +1,71 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.NumberHelper;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Display;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class BrightnessCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ if (!(hologram.getData() instanceof DisplayHologramData displayData)) {
+ MessageHelper.error(player, "This command can only be used on display holograms");
+ return false;
+ }
+
+ if(args.length < 5) {
+ MessageHelper.error(player, "You must provide a brightness type and value.");
+ return false;
+ }
+
+ final var brightnessType = args[3];
+
+ if(!brightnessType.equalsIgnoreCase("block") && !brightnessType.equalsIgnoreCase("sky")) {
+ MessageHelper.error(player, "Invalid brightness type, valid options are BLOCK or SKY");
+ return false;
+ }
+
+ final var parsedNumber = NumberHelper.parseInt(args[4]);
+
+ if(parsedNumber.isEmpty()) {
+ MessageHelper.error(player, "Invalid brightness value.");
+ return false;
+ }
+
+ final var brightnessValue = parsedNumber.get();
+
+ if(brightnessValue < 0 || brightnessValue > 15) {
+ MessageHelper.error(player, "Invalid brightness value, must be between 0 and 15");
+ return false;
+ }
+
+ final var currentBrightness = displayData.getBrightness();
+ final var blockBrightness = brightnessType.equalsIgnoreCase("block") ? brightnessValue :
+ currentBrightness == null ? 0 : currentBrightness.getBlockLight();
+ final var skyBrightness = brightnessType.equalsIgnoreCase("sky") ? brightnessValue :
+ currentBrightness == null ? 0 : currentBrightness.getSkyLight();
+
+ displayData.setBrightness(new Display.Brightness(blockBrightness, skyBrightness));
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed " + brightnessType.toLowerCase() + " brightness to " + brightnessValue);
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CenterCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CenterCMD.java
new file mode 100644
index 00000000..00b72a28
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CenterCMD.java
@@ -0,0 +1,52 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.Formats;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class CenterCMD implements Subcommand {
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.center"))) {
+ MessageHelper.error(player, "You don't have the required permission to center a hologram");
+ return false;
+ }
+
+ Location location = hologram.getData().getLocation();
+
+ location.set(
+ Math.floor(location.x()) + 0.5,
+ location.y(),
+ Math.floor(location.z()) + 0.5
+ );
+
+ hologram.getData().setLocation(location);
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Centered the hologram to %s/%s/%s %s\u00B0 %s\u00B0".formatted(
+ Formats.COORDINATES_DECIMAL.format(location.x()),
+ Formats.COORDINATES_DECIMAL.format(location.y()),
+ Formats.COORDINATES_DECIMAL.format(location.z()),
+ Formats.COORDINATES_DECIMAL.format((location.getYaw() + 180f) % 360f),
+ Formats.COORDINATES_DECIMAL.format((location.getPitch()) % 360f)
+ ));
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CopyCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CopyCMD.java
new file mode 100644
index 00000000..1a66bc41
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CopyCMD.java
@@ -0,0 +1,80 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.events.HologramCreateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class CopyCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+ if (!(sender.hasPermission("fancyholograms.hologram.copy"))) {
+ MessageHelper.error(sender, "You don't have the required permission to clone a hologram");
+ return false;
+ }
+
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+
+ if (args.length < 3) {
+ MessageHelper.error(sender, "Wrong usage: /hologram help");
+ return false;
+ }
+
+ String name = args[2];
+
+ if (FancyHologramsPlugin.get().getRegistry().get(name).isPresent()) {
+ MessageHelper.error(sender, "There already exists a hologram with this name");
+ return false;
+ }
+
+ if (name.contains(".")) {
+ MessageHelper.error(sender, "The name of the hologram cannot contain a dot");
+ return false;
+ }
+
+ final var data = hologram.getData().copy(name);
+ Location originalLocation = data.getLocation();
+ Location location = player.getLocation();
+ location.setPitch(originalLocation.getPitch());
+ location.setYaw(originalLocation.getYaw());
+ data.setLocation(location);
+
+ final var copy = FancyHologramsPlugin.get().getHologramFactory().apply(data);
+
+ if (!new HologramCreateEvent(copy, player).callEvent()) {
+ MessageHelper.error(sender, "Creating the copied hologram was cancelled");
+ return false;
+ }
+
+ FancyHologramsPlugin.get().getController().refreshHologram(copy, Bukkit.getOnlinePlayers());
+
+ FancyHologramsPlugin.get().getRegistry().register(copy);
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(sender, "Copied the hologram");
+ return true;
+
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CreateCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CreateCMD.java
new file mode 100644
index 00000000..1a798635
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/CreateCMD.java
@@ -0,0 +1,90 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.BlockHologramData;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.data.ItemHologramData;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramCreateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.api.hologram.HologramType;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Bukkit;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Display;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class CreateCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(sender.hasPermission("fancyholograms.hologram.create"))) {
+ MessageHelper.error(sender, "You don't have the required permission to create a hologram");
+ return false;
+ }
+
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+ if (args.length < 3) {
+ MessageHelper.error(player, "Wrong usage: /hologram help");
+ return false;
+ }
+
+ HologramType type = HologramType.getByName(args[1]);
+ if (type == null) {
+ MessageHelper.error(player, "Could not find type: " + args[1]);
+ return false;
+ }
+
+ String name = args[2];
+
+ if (FancyHologramsPlugin.get().getRegistry().get(name).isPresent()) {
+ MessageHelper.error(player, "There already exists a hologram with this name");
+ return false;
+ }
+
+ if (name.contains(".")) {
+ MessageHelper.error(player, "The name of the hologram cannot contain a dot");
+ return false;
+ }
+
+ DisplayHologramData displayData = null;
+ switch (type) {
+ case TEXT -> displayData = new TextHologramData(name, player.getLocation());
+ case ITEM -> {
+ displayData = new ItemHologramData(name, player.getLocation());
+ displayData.setBillboard(Display.Billboard.FIXED);
+ }
+ case BLOCK -> {
+ displayData = new BlockHologramData(name, player.getLocation());
+ displayData.setBillboard(Display.Billboard.FIXED);
+ }
+ }
+
+ final var holo = FancyHologramsPlugin.get().getHologramFactory().apply(displayData);
+ if (!new HologramCreateEvent(holo, player).callEvent()) {
+ MessageHelper.error(player, "Creating the hologram was cancelled");
+ return false;
+ }
+
+ FancyHologramsPlugin.get().getController().refreshHologram(holo, Bukkit.getOnlinePlayers());
+
+ FancyHologramsPlugin.get().getRegistry().register(holo);
+
+ MessageHelper.success(player, "Created the hologram");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InfoCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InfoCMD.java
new file mode 100644
index 00000000..d147cce8
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InfoCMD.java
@@ -0,0 +1,82 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.api.data.*;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3f;
+
+import java.util.List;
+
+public class InfoCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.info"))) {
+ MessageHelper.error(player, "You don't have the required permission to view the info of a hologram");
+ return false;
+ }
+
+ HologramData data = hologram.getData();
+
+ MessageHelper.info(player, "Information about the " + hologram.getData().getName() + " hologram:");
+ MessageHelper.info(player, "Name: " + hologram.getData().getName());
+ MessageHelper.info(player, "Type: " + hologram.getData().getType().name());
+ MessageHelper.info(player, "Location: " + data.getLocation().getWorld().getName() + " " + data.getLocation().getX() + " / " + data.getLocation().getY() + " / " + data.getLocation().getZ());
+ MessageHelper.info(player, "Visibility distance: " + data.getVisibilityDistance() + " blocks");
+
+ if (data instanceof DisplayHologramData displayData) {
+ Vector3f scale = displayData.getScale();
+ if (scale.x() == scale.y() && scale.y() == scale.z()) {
+ MessageHelper.info(player, "Scale: x" + displayData.getScale().x());
+ } else {
+ MessageHelper.info(player, "Scale: " + displayData.getScale().x() + ", " + displayData.getScale().y() + ", " + displayData.getScale().z());
+ }
+
+ MessageHelper.info(player, "Billboard: " + displayData.getBillboard().name());
+ MessageHelper.info(player, "Shadow radius: " + displayData.getShadowRadius());
+ MessageHelper.info(player, "Shadow strength: " + displayData.getShadowStrength());
+ }
+
+ if (data.getLinkedNpcName() != null) {
+ MessageHelper.info(player, "Linked npc: " + data.getLinkedNpcName());
+ }
+
+ if (data instanceof TextHologramData textData) {
+ MessageHelper.info(player, "Text: ");
+ for (String line : textData.getText()) {
+ MessageHelper.info(player, " " + line);
+ }
+
+ if (textData.getBackground() != null) {
+ MessageHelper.info(player, "Background: " + '#' + Integer.toHexString(textData.getBackground().asARGB()));
+ } else {
+ MessageHelper.info(player, "Background: default");
+ }
+
+ MessageHelper.info(player, "Text alignment: " + textData.getTextAlignment().name());
+ MessageHelper.info(player, "See through: " + (textData.isSeeThrough() ? "enabled" : "disabled"));
+ MessageHelper.info(player, "Text shadow: " + (textData.hasTextShadow() ? "enabled" : "disabled"));
+ if (textData.getTextUpdateInterval() == -1) {
+ MessageHelper.info(player, "Update text interval: not updating");
+ } else {
+ MessageHelper.info(player, "Update text interval: " + textData.getTextUpdateInterval() + " ticks");
+ }
+ } else if (data instanceof BlockHologramData blockData) {
+ MessageHelper.info(player, "Block: " + blockData.getBlock().name());
+ } else if (data instanceof ItemHologramData itemData) {
+ MessageHelper.info(player, "Item: " + itemData.getItemStack().getType().name());
+ }
+
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InsertAfterCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InsertAfterCMD.java
new file mode 100644
index 00000000..65235495
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InsertAfterCMD.java
@@ -0,0 +1,80 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class InsertAfterCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.insert_after"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ var index = Ints.tryParse(args[3]);
+ if (index == null) {
+ MessageHelper.error(player, "Could not parse line number");
+ return false;
+ }
+
+ if (index < 0) {
+ MessageHelper.error(player, "Invalid line index");
+ return false;
+ }
+
+ String text = "";
+ for (int i = 4; i < args.length; i++) {
+ text += args[i] + " ";
+ }
+
+ if (text.isEmpty()) {
+ MessageHelper.error(player, "You need to provide a text to insert");
+ return true;
+ }
+
+ text = text.substring(0, text.length() - 1);
+
+ final var lines = new ArrayList<>(textData.getText());
+ lines.add(Math.min(index, lines.size()), text);
+
+ final var copied = textData.copy(textData.getName());
+ copied.setText(lines);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.TEXT)) {
+ return false;
+ }
+
+ textData.setText(copied.getText());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Inserted line");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InsertBeforeCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InsertBeforeCMD.java
new file mode 100644
index 00000000..63e6b238
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/InsertBeforeCMD.java
@@ -0,0 +1,82 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class InsertBeforeCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.insert_before"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ var index = Ints.tryParse(args[3]);
+ if (index == null) {
+ MessageHelper.error(player, "Could not parse line number");
+ return false;
+ }
+
+ index--;
+
+ if (index < 0) {
+ MessageHelper.error(player, "Invalid line index");
+ return false;
+ }
+
+ String text = "";
+ for (int i = 4; i < args.length; i++) {
+ text += args[i] + " ";
+ }
+
+ if (text.isEmpty()) {
+ MessageHelper.error(player, "You need to provide a text to insert");
+ return true;
+ }
+
+ text = text.substring(0, text.length() - 1);
+
+ final var lines = new ArrayList<>(textData.getText());
+ lines.add(Math.min(index, lines.size()), text);
+
+ final var copied = textData.copy(textData.getName());
+ copied.setText(lines);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.TEXT)) {
+ return false;
+ }
+
+ textData.setText(copied.getText());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Inserted line");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ItemCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ItemCMD.java
new file mode 100644
index 00000000..2cf63798
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ItemCMD.java
@@ -0,0 +1,76 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.ItemHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Material;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class ItemCMD implements Subcommand {
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(sender.hasPermission("fancyholograms.hologram.edit.item"))) {
+ MessageHelper.error(sender, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof ItemHologramData itemData)) {
+ MessageHelper.error(player, "This command can only be used on item holograms");
+ return false;
+ }
+
+ ItemStack item = player.getInventory().getItemInMainHand();
+ if (item.getType() == Material.AIR || item.getAmount() < 1) {
+ MessageHelper.error(player, "You need to hold an item in your hand");
+ return false;
+ }
+
+
+ if (item == itemData.getItemStack()) {
+ MessageHelper.warning(player, "This item is already set");
+ return false;
+ }
+
+ final var copied = itemData.copy(itemData.getName());
+ copied.setItemStack(item);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.BILLBOARD)) {
+ return false;
+ }
+
+ if (copied.getItemStack() == itemData.getItemStack()) {
+ MessageHelper.warning(player, "This item is already set");
+ return false;
+ }
+
+ itemData.setItemStack(item);
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Set the item to '" + item.getType().name() + "'");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/LinkWithNpcCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/LinkWithNpcCMD.java
new file mode 100644
index 00000000..5cf24d4d
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/LinkWithNpcCMD.java
@@ -0,0 +1,60 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.PluginUtils;
+import de.oliver.fancylib.MessageHelper;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class LinkWithNpcCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.link"))) {
+ MessageHelper.error(player, "You don't have the required permission to link a hologram");
+ return false;
+ }
+
+ if (!PluginUtils.isFancyNpcsEnabled()) {
+ MessageHelper.warning(player, "You need to install the FancyNpcs plugin for this functionality to work");
+ MessageHelper.warning(player, "Download link: click here.");
+ return false;
+ }
+
+ String name = args[3];
+
+ if (hologram.getData().getLinkedNpcName() != null) {
+ MessageHelper.error(player, "This hologram is already linked with an NPC");
+ return false;
+ }
+
+ final var npc = FancyNpcsPlugin.get().getNpcManager().getNpc(name);
+ if (npc == null) {
+ MessageHelper.error(player, "Could not find NPC with that name");
+ return false;
+ }
+
+ hologram.getData().setLinkedNpcName(npc.getData().getName());
+
+ FancyHologramsPlugin.get().getControllerImpl().syncHologramWithNpc(hologram);
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Linked hologram with NPC");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ListCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ListCMD.java
new file mode 100644
index 00000000..16a62a58
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ListCMD.java
@@ -0,0 +1,78 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.Formats;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class ListCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.list"))) {
+ MessageHelper.error(player, "You don't have the required permission to list the holograms");
+ return false;
+ }
+
+ final var holograms = FancyHologramsPlugin.get().getRegistry().getAllPersistent();
+
+ if (holograms.isEmpty()) {
+ MessageHelper.warning(player, "There are no holograms. Use '/hologram create' to create one");
+ } else {
+ int page;
+ if (args.length < 2) {
+ page = 1;
+ } else {
+ final var index = Ints.tryParse(args[1]);
+ if (index == null) {
+ MessageHelper.error(player, "Could not parse page number");
+ return false;
+ }
+ page = index;
+ }
+
+ var pages = holograms.size() / 10 + 1;
+ if (page > pages) {
+ MessageHelper.error(player, "Page %s does not exist".formatted(page));
+ return true;
+ }
+ MessageHelper.info(player, "List of holograms:");
+ MessageHelper.info(player, "Page %s/%s".formatted(page, pages));
+ holograms.stream()
+ .skip((page - 1) * 10)
+ .limit(10)
+ .forEach(holo -> {
+ final var location = holo.getData().getLocation();
+ if (location == null || location.getWorld() == null) {
+ return;
+ }
+
+ MessageHelper.info(player,
+ "Click to teleport'> - %s (%s/%s/%s in %s)"
+ .formatted("/hologram teleport " + holo.getData().getName(),
+ holo.getData().getName(),
+ Formats.DECIMAL.format(location.x()),
+ Formats.DECIMAL.format(location.y()),
+ Formats.DECIMAL.format(location.z()),
+ location.getWorld().getName()
+ ));
+ });
+
+ }
+
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/MoveHereCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/MoveHereCMD.java
new file mode 100644
index 00000000..3d6312f7
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/MoveHereCMD.java
@@ -0,0 +1,94 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Doubles;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.Formats;
+import de.oliver.fancylib.MessageHelper;
+import org.apache.commons.lang3.StringUtils;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.function.Function;
+
+public class MoveHereCMD implements Subcommand {
+
+ public static boolean setLocation(Player player, Hologram hologram, Location location, boolean applyRotation) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.move_here"))) {
+ MessageHelper.error(player, "You don't have the required permission to move a hologram");
+ return false;
+ }
+
+ final var copied = hologram.getData().copy(hologram.getData().getName());
+ final Location newLocation = (applyRotation)
+ ? location
+ : new Location(location.getWorld(), location.x(), location.y(), location.z(), copied.getLocation().getYaw(), copied.getLocation().getPitch());
+ copied.setLocation(newLocation);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.POSITION)) {
+ return false;
+ }
+
+ hologram.getData().setLocation(copied.getLocation());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Moved the hologram to %s/%s/%s %s\u00B0 %s\u00B0".formatted(
+ Formats.COORDINATES_DECIMAL.format(newLocation.x()),
+ Formats.COORDINATES_DECIMAL.format(newLocation.y()),
+ Formats.COORDINATES_DECIMAL.format(newLocation.z()),
+ Formats.COORDINATES_DECIMAL.format((newLocation.getYaw() + 180f) % 360f),
+ Formats.COORDINATES_DECIMAL.format((newLocation.getPitch()) % 360f)
+ ));
+
+ return true;
+ }
+
+ public static @Nullable Double calculateCoordinate(@NotNull final String text, @Nullable final Location originLocation, @NotNull final Location callerLocation, @NotNull final Function extractor) {
+ final var number = Doubles.tryParse(StringUtils.stripStart(text, "~"));
+ final var target = text.startsWith("~~") ? callerLocation : text.startsWith("~") ? originLocation : null;
+
+ if (number == null) {
+ return target == null ? null : extractor.apply(target).doubleValue();
+ }
+
+ if (target == null) {
+ return number;
+ }
+
+ return number + extractor.apply(target).doubleValue();
+ }
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+ if (hologram.getData().getLinkedNpcName() != null) {
+ MessageHelper.error(player, "This hologram is linked with an NPC");
+ MessageHelper.error(player, "To unlink: /hologram edit " + hologram.getData().getName() + " unlinkWithNpc");
+ return false;
+ }
+
+ final var location = player.getLocation();
+
+ return setLocation(player, hologram, location, false);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/MoveToCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/MoveToCMD.java
new file mode 100644
index 00000000..f921c580
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/MoveToCMD.java
@@ -0,0 +1,73 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class MoveToCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(sender.hasPermission("fancyholograms.hologram.edit.move_to"))) {
+ MessageHelper.error(sender, "You don't have the required permission to move a hologram");
+ return false;
+ }
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+ if (args.length < 3) {
+ MessageHelper.error(player, "Wrong usage: /hologram help");
+ return false;
+ }
+
+ final var x = MoveHereCMD.calculateCoordinate(args[3], hologram.getData().getLocation(), player.getLocation(), Location::x);
+ final var y = MoveHereCMD.calculateCoordinate(args[4], hologram.getData().getLocation(), player.getLocation(), Location::y);
+ final var z = MoveHereCMD.calculateCoordinate(args[5], hologram.getData().getLocation(), player.getLocation(), Location::z);
+
+ if (x == null || y == null || z == null) {
+ MessageHelper.error(player, "Could not parse position");
+ return false;
+ }
+
+ final var location = new Location(player.getWorld(), x, y, z, hologram.getData().getLocation().getYaw(), hologram.getData().getLocation().getPitch());
+
+ if (args.length > 6) {
+ final var yaw = MoveHereCMD.calculateCoordinate(args[6], hologram.getData().getLocation(), player.getLocation(), loc -> loc.getYaw() + 180f);
+
+ if (yaw == null) {
+ MessageHelper.error(player, "Could not parse yaw");
+ return false;
+ }
+
+ location.setYaw(yaw.floatValue() - 180f);
+ }
+
+ if (args.length > 7) {
+ final var pitch = MoveHereCMD.calculateCoordinate(args[7], hologram.getData().getLocation(), player.getLocation(), Location::getPitch);
+
+ if (pitch == null) {
+ MessageHelper.error(player, "Could not parse pitch");
+ return false;
+ }
+
+ location.setPitch(pitch.floatValue());
+ }
+
+ return MoveHereCMD.setLocation(player, hologram, location, true);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/NearbyCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/NearbyCMD.java
new file mode 100644
index 00000000..3fcd2b60
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/NearbyCMD.java
@@ -0,0 +1,96 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.Formats;
+import de.oliver.fancyholograms.util.NumberHelper;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public class NearbyCMD implements Subcommand {
+
+ public static final String INVALID_NEARBY_RANGE = "Provide an integer radius to search for holograms nearby.";
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.nearby"))) {
+ MessageHelper.error(player, "You don't have the required permission to see nearby holograms");
+ return false;
+ }
+
+ if (!(player instanceof Player)) {
+ MessageHelper.error(player, "This is a player only command.");
+ return false;
+ }
+
+
+ if (args.length < 2) {
+ MessageHelper.error(player, INVALID_NEARBY_RANGE);
+ return false;
+ }
+
+ Optional range = NumberHelper.parseInt(args[1]);
+
+ if (range.isEmpty()) {
+ MessageHelper.error(player, INVALID_NEARBY_RANGE);
+ return false;
+ }
+
+ Location playerLocation = ((Player) player).getLocation().clone();
+
+ List> nearby = FancyHologramsPlugin.get()
+ .getRegistry()
+ .getAllPersistent()
+ .stream()
+ .filter((holo) -> holo.getData().getLocation().getWorld() == playerLocation.getWorld())
+ .map((holo) -> Map.entry(holo, holo.getData().getLocation().distance(playerLocation)))
+ .filter((entry) -> entry.getValue() <= range.get())
+ .sorted(Comparator.comparingInt(a -> a.getValue().intValue()))
+ .toList();
+
+ if (nearby.isEmpty()) {
+ MessageHelper.error(player, "There are no nearby holograms in a radius of %s blocks.".formatted(range.get()));
+ return true;
+ }
+
+ MessageHelper.info(player, "Holograms nearby (%s radius)".formatted(range.get()));
+ nearby.forEach((entry) -> {
+ Hologram holo = entry.getKey();
+ double distance = entry.getValue();
+
+ final var location = holo.getData().getLocation();
+ if (location == null || location.getWorld() == null) {
+ return;
+ }
+
+ MessageHelper.info(player,
+ "Click to teleport'> - %s (%s/%s/%s in %s, %s blocks away)"
+ .formatted(
+ "/hologram teleport " + holo.getData().getName(),
+ holo.getData().getName(),
+ Formats.DECIMAL.format(location.x()),
+ Formats.DECIMAL.format(location.y()),
+ Formats.DECIMAL.format(location.z()),
+ location.getWorld().getName(),
+ Formats.DECIMAL.format(distance)
+ ));
+ });
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RemoveCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RemoveCMD.java
new file mode 100644
index 00000000..e94edd0b
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RemoveCMD.java
@@ -0,0 +1,42 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.FancyHolograms;
+import de.oliver.fancyholograms.api.events.HologramDeleteEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class RemoveCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.remove"))) {
+ MessageHelper.error(player, "You don't have the required permission to remove a hologram");
+ return false;
+ }
+
+ if (!new HologramDeleteEvent(hologram, player).callEvent()) {
+ MessageHelper.error(player, "Removing the hologram was cancelled");
+ return false;
+ }
+
+ FancyHolograms.get().getHologramThread().submit(() -> {
+ FancyHologramsPlugin.get().getRegistry().unregister(hologram);
+ MessageHelper.success(player, "Removed the hologram");
+ });
+
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RemoveLineCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RemoveLineCMD.java
new file mode 100644
index 00000000..26b93612
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RemoveLineCMD.java
@@ -0,0 +1,42 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class RemoveLineCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.line.remove"))) {
+ MessageHelper.error(player, "You don't have the required permission to remove a line from a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ final var index = Ints.tryParse(args[3]);
+ if (index == null) {
+ MessageHelper.error(player, "Could not parse line number");
+ return false;
+ }
+
+ return SetLineCMD.setLine(player, hologram, index - 1, null);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RotateCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RotateCMD.java
new file mode 100644
index 00000000..ea2be61c
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RotateCMD.java
@@ -0,0 +1,40 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class RotateCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(sender.hasPermission("fancyholograms.hologram.edit.rotate"))) {
+ MessageHelper.error(sender, "You don't have the required permission to rotate a hologram");
+ return false;
+ }
+
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+ final var yaw = MoveHereCMD.calculateCoordinate(args[3], hologram.getData().getLocation(), player.getLocation(), loc -> loc.getYaw() + 180f);
+ Location location = hologram.getData().getLocation().clone();
+ location.setYaw(yaw.floatValue() - 180f);
+
+ return MoveHereCMD.setLocation(player, hologram, location, true);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RotatePitchCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RotatePitchCMD.java
new file mode 100644
index 00000000..8757be35
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/RotatePitchCMD.java
@@ -0,0 +1,40 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class RotatePitchCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(sender.hasPermission("fancyholograms.hologram.edit.rotate_pitch"))) {
+ MessageHelper.error(sender, "You don't have the required permission to rotate a hologram");
+ return false;
+ }
+
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+ final var pitch = MoveHereCMD.calculateCoordinate(args[3], hologram.getData().getLocation(), player.getLocation(), loc -> loc.getPitch() - 180f);
+ Location location = hologram.getData().getLocation().clone();
+ location.setPitch(pitch.floatValue());
+
+ return MoveHereCMD.setLocation(player, hologram, location, true);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ScaleCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ScaleCMD.java
new file mode 100644
index 00000000..3e665950
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ScaleCMD.java
@@ -0,0 +1,80 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Floats;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3f;
+
+import java.util.List;
+
+public class ScaleCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.scale"))) {
+ MessageHelper.error(player, "You don't have the required permission to change the scale of a hologram");
+ return false;
+ }
+
+ final var scaleX = Floats.tryParse(args[3]);
+ final var scaleY = args.length >= 6 ? Floats.tryParse(args[4]) : scaleX;
+ final var scaleZ = args.length >= 6 ? Floats.tryParse(args[5]) : scaleX;
+
+ if (scaleX == null || scaleY == null || scaleZ == null) {
+ MessageHelper.error(player, "Could not parse scale");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof DisplayHologramData displayData)) {
+ MessageHelper.error(player, "This command can only be used on display holograms");
+ return false;
+ }
+
+ if (Float.compare(scaleX, displayData.getScale().x()) == 0 &&
+ Float.compare(scaleY, displayData.getScale().y()) == 0 &&
+ Float.compare(scaleZ, displayData.getScale().z()) == 0) {
+ MessageHelper.warning(player, "This hologram is already at this scale");
+ return false;
+ }
+
+ final var copied = displayData.copy(displayData.getName());
+ copied.setScale(new Vector3f(scaleX, scaleY, scaleZ));
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.SCALE)) {
+ return false;
+ }
+
+ if (Float.compare(copied.getScale().x(), displayData.getScale().x()) == 0 &&
+ Float.compare(copied.getScale().y(), displayData.getScale().y()) == 0 &&
+ Float.compare(copied.getScale().z(), displayData.getScale().z()) == 0) {
+ MessageHelper.warning(player, "This hologram is already at this scale");
+ return false;
+ }
+
+ displayData.setScale(new Vector3f(
+ copied.getScale().x(),
+ copied.getScale().y(),
+ copied.getScale().z()));
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed scale to " + scaleX + ", " + scaleY + ", " + scaleZ);
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/SeeThroughCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/SeeThroughCMD.java
new file mode 100644
index 00000000..1b75f611
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/SeeThroughCMD.java
@@ -0,0 +1,74 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+
+public class SeeThroughCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.see_trough"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ final var enabled = switch (args[3].toLowerCase(Locale.ROOT)) {
+ case "true" -> true;
+ case "false" -> false;
+ default -> null;
+ };
+
+ if (enabled == null) {
+ MessageHelper.error(player, "Could not parse see through flag");
+ return false;
+ }
+
+ if (enabled == textData.isSeeThrough()) {
+ MessageHelper.warning(player, "This hologram already has see through " + (enabled ? "enabled" : "disabled"));
+ return false;
+ }
+
+ final var copied = textData.copy(textData.getName());
+ copied.setSeeThrough(enabled);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.SEE_THROUGH)) {
+ return false;
+ }
+
+ if (enabled == textData.isSeeThrough()) {
+ MessageHelper.warning(player, "This hologram already has see through " + (enabled ? "enabled" : "disabled"));
+ return false;
+ }
+
+ textData.setSeeThrough(copied.isSeeThrough());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed see through");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/SetLineCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/SetLineCMD.java
new file mode 100644
index 00000000..630a1cc8
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/SetLineCMD.java
@@ -0,0 +1,87 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SetLineCMD implements Subcommand {
+
+ public static boolean setLine(CommandSender player, Hologram hologram, int index, String text) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.line.set"))) {
+ MessageHelper.error(player, "You don't have the required permission to set a line to this hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ final var lines = new ArrayList<>(textData.getText());
+
+ if (index >= lines.size()) {
+ lines.add(text == null ? " " : text);
+ } else if (text == null) {
+ lines.remove(index);
+ } else {
+ lines.set(index, text);
+ }
+
+ final var copied = textData.copy(textData.getName());
+ copied.setText(lines);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.TEXT)) {
+ return false;
+ }
+
+ textData.setText(copied.getText());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed text for line " + (Math.min(index, lines.size() - 1) + 1));
+ return true;
+ }
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ var index = Ints.tryParse(args[3]);
+ if (index == null) {
+ MessageHelper.error(player, "Could not parse line number");
+ return false;
+ }
+
+ if (index < 0) {
+ MessageHelper.error(player, "Invalid line index");
+ return false;
+ }
+
+ index--;
+
+ String text = "";
+ for (int i = 4; i < args.length; i++) {
+ text += args[i] + " ";
+ }
+ text = text.substring(0, text.length() - 1);
+
+ return setLine(player, hologram, index, text);
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ShadowRadiusCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ShadowRadiusCMD.java
new file mode 100644
index 00000000..429ab600
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ShadowRadiusCMD.java
@@ -0,0 +1,70 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Floats;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class ShadowRadiusCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.shadow_radius"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ final var radius = Floats.tryParse(args[3]);
+
+ if (radius == null) {
+ MessageHelper.error(player, "Could not parse shadow radius");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof DisplayHologramData displayData)) {
+ MessageHelper.error(player, "This command can only be used on display holograms");
+ return false;
+ }
+
+ if (Float.compare(radius, displayData.getShadowRadius()) == 0) {
+ MessageHelper.warning(player, "This hologram already has this shadow radius");
+ return false;
+ }
+
+ final var copied = displayData.copy(displayData.getName());
+ copied.setShadowRadius(radius);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.SHADOW_RADIUS)) {
+ return false;
+ }
+
+ if (Float.compare(copied.getShadowRadius(), displayData.getShadowRadius()) == 0) {
+ MessageHelper.warning(player, "This hologram already has this shadow radius");
+ return false;
+ }
+
+ displayData.setShadowRadius(copied.getShadowRadius());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed shadow radius");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ShadowStrengthCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ShadowStrengthCMD.java
new file mode 100644
index 00000000..221e869f
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/ShadowStrengthCMD.java
@@ -0,0 +1,70 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Floats;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class ShadowStrengthCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.shadow_strength"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ final var strength = Floats.tryParse(args[3]);
+
+ if (strength == null) {
+ MessageHelper.error(player, "Could not parse shadow strength");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof DisplayHologramData displayData)) {
+ MessageHelper.error(player, "This command can only be used on display holograms");
+ return false;
+ }
+
+ if (Float.compare(strength, displayData.getShadowStrength()) == 0) {
+ MessageHelper.warning(player, "This hologram already has this shadow strength");
+ return false;
+ }
+
+ final var copied = displayData.copy(displayData.getName());
+ copied.setShadowStrength(strength);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.SHADOW_STRENGTH)) {
+ return false;
+ }
+
+ if (Float.compare(copied.getShadowStrength(), displayData.getShadowStrength()) == 0) {
+ MessageHelper.warning(player, "This hologram already has this shadow strength");
+ return false;
+ }
+
+ displayData.setShadowStrength(copied.getShadowStrength());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed shadow strength");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TeleportCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TeleportCMD.java
new file mode 100644
index 00000000..9335a716
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TeleportCMD.java
@@ -0,0 +1,47 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class TeleportCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender sender, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(sender.hasPermission("fancyholograms.hologram.teleport"))) {
+ MessageHelper.error(sender, "You don't have the required permission to teleport you to a hologram");
+ return false;
+ }
+
+ if (!(sender instanceof Player player)) {
+ MessageHelper.error(sender, "You must be a sender to use this command");
+ return false;
+ }
+
+ final var location = hologram.getData().getLocation();
+
+ if (location == null || location.getWorld() == null) {
+ MessageHelper.error(player, "Could not teleport to the hologram");
+ return false;
+ }
+
+ player.teleportAsync(location).thenAccept(success -> {
+ if (success) MessageHelper.success(player, "Teleported you to the hologram");
+ else MessageHelper.error(player, "Could not teleport to the hologram");
+ });
+
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TextAlignmentCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TextAlignmentCMD.java
new file mode 100644
index 00000000..0d26a247
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TextAlignmentCMD.java
@@ -0,0 +1,72 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.base.Enums;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.TextDisplay;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+
+public class TextAlignmentCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.text_alignment"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ final var alignment = Enums.getIfPresent(TextDisplay.TextAlignment.class, args[3].toUpperCase(Locale.ROOT)).orNull();
+
+ if (alignment == null) {
+ MessageHelper.error(player, "Could not parse text alignment");
+ return false;
+ }
+
+ if (textData.getTextAlignment() == alignment) {
+ MessageHelper.warning(player, "This hologram already has this text alignment");
+ return false;
+ }
+
+ final var copied = textData.copy(textData.getName());
+ copied.setTextAlignment(alignment);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.TEXT_ALIGNMENT)) {
+ return false;
+ }
+
+ if (textData.getTextAlignment() == alignment) {
+ MessageHelper.warning(player, "This hologram already has this text alignment");
+ return false;
+ }
+
+ textData.setTextAlignment(((TextHologramData) copied).getTextAlignment());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed text alignment");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TextShadowCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TextShadowCMD.java
new file mode 100644
index 00000000..6d8b2663
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TextShadowCMD.java
@@ -0,0 +1,74 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+
+public class TextShadowCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.text_shadow"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ final var enabled = switch (args[3].toLowerCase(Locale.ROOT)) {
+ case "true" -> true;
+ case "false" -> false;
+ default -> null;
+ };
+
+ if (enabled == null) {
+ MessageHelper.error(player, "Could not parse text shadow flag");
+ return false;
+ }
+
+ if (enabled == textData.hasTextShadow()) {
+ MessageHelper.warning(player, "This hologram already has text shadow " + (enabled ? "enabled" : "disabled"));
+ return false;
+ }
+
+ final var copied = textData.copy(textData.getName());
+ copied.setTextShadow(enabled);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.TEXT_SHADOW)) {
+ return false;
+ }
+
+ if (enabled == textData.hasTextShadow()) {
+ MessageHelper.warning(player, "This hologram already has text shadow " + (enabled ? "enabled" : "disabled"));
+ return false;
+ }
+
+ textData.setTextShadow(copied.hasTextShadow());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed text shadow");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TranslateCommand.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TranslateCommand.java
new file mode 100644
index 00000000..a8b1effd
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/TranslateCommand.java
@@ -0,0 +1,80 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Floats;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3f;
+
+import java.util.List;
+
+public class TranslateCommand implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.translate"))) {
+ MessageHelper.error(player, "You don't have the required permission to change the translation of a hologram");
+ return false;
+ }
+
+ final var translateX = Floats.tryParse(args[3]);
+ final var translateY = args.length >= 6 ? Floats.tryParse(args[4]) : translateX;
+ final var translateZ = args.length >= 6 ? Floats.tryParse(args[5]) : translateX;
+
+ if (translateX == null || translateY == null || translateZ == null) {
+ MessageHelper.error(player, "Could not parse translation");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof DisplayHologramData displayData)) {
+ MessageHelper.error(player, "This command can only be used on display holograms");
+ return false;
+ }
+
+ if (Float.compare(translateX, displayData.getTranslation().x()) == 0 &&
+ Float.compare(translateY, displayData.getTranslation().y()) == 0 &&
+ Float.compare(translateZ, displayData.getTranslation().z()) == 0) {
+ MessageHelper.warning(player, "This hologram is already at this translation");
+ return false;
+ }
+
+ final var copied = displayData.copy(displayData.getName());
+ copied.setTranslation(new Vector3f(translateX, translateY, translateZ));
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.TRANSLATION)) {
+ return false;
+ }
+
+ if (Float.compare(copied.getTranslation().x(), displayData.getTranslation().x()) == 0 &&
+ Float.compare(copied.getTranslation().y(), displayData.getTranslation().y()) == 0 &&
+ Float.compare(copied.getTranslation().z(), displayData.getTranslation().z()) == 0) {
+ MessageHelper.warning(player, "This hologram is already at this translation");
+ return false;
+ }
+
+ displayData.setTranslation(new Vector3f(
+ copied.getTranslation().x(),
+ copied.getTranslation().y(),
+ copied.getTranslation().z()));
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed translation to " + translateX + ", " + translateY + ", " + translateZ);
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/UnlinkWithNpcCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/UnlinkWithNpcCMD.java
new file mode 100644
index 00000000..c1e56469
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/UnlinkWithNpcCMD.java
@@ -0,0 +1,57 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancyholograms.util.PluginUtils;
+import de.oliver.fancylib.MessageHelper;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class UnlinkWithNpcCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.unlink"))) {
+ MessageHelper.error(player, "You don't have the required permission to unlink a hologram");
+ return false;
+ }
+
+ if (!PluginUtils.isFancyNpcsEnabled()) {
+ MessageHelper.warning(player, "You need to install the FancyNpcs plugin for this functionality to work");
+ MessageHelper.warning(player, "Download link: click here.");
+ return false;
+ }
+
+ if (hologram.getData().getLinkedNpcName() == null) {
+ MessageHelper.error(player, "This hologram is not linked with an NPC");
+ return false;
+ }
+
+ final var npc = FancyNpcsPlugin.get().getNpcManager().getNpc(hologram.getData().getLinkedNpcName());
+
+ hologram.getData().setLinkedNpcName(null);
+
+ if (npc != null) {
+ npc.getData().setDisplayName(npc.getData().getName());
+ npc.updateForAll();
+ }
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Unlinked hologram with NPC");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/UpdateTextIntervalCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/UpdateTextIntervalCMD.java
new file mode 100644
index 00000000..da44c899
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/UpdateTextIntervalCMD.java
@@ -0,0 +1,97 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+
+public class UpdateTextIntervalCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.text_interval"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ if (!(hologram.getData() instanceof TextHologramData textData)) {
+ MessageHelper.error(player, "This command can only be used on text holograms");
+ return false;
+ }
+
+ final var text = args[3].toLowerCase(Locale.ROOT);
+
+ Integer interval;
+
+ if (text.equals("never") || text.equals("off") || text.equals("none")) {
+ interval = -1;
+ } else {
+
+ var multiplier = 1;
+
+ if (!text.isEmpty()) {
+ switch (text.charAt(text.length() - 1)) {
+ case 's' -> multiplier = 1000;
+ case 'm' -> multiplier = 1000 * 60;
+ }
+ }
+
+ final var time = Ints.tryParse(multiplier == 1 ? text : text.substring(0, text.length() - 1));
+
+ if (time == null) {
+ interval = null;
+ } else {
+ interval = time * multiplier;
+ }
+ }
+
+ if (interval == null) {
+ MessageHelper.error(player, "Could not parse text update interval");
+ return false;
+ }
+
+ if (interval == textData.getTextUpdateInterval()) {
+ MessageHelper.warning(player, "This hologram already has this text update interval");
+ return false;
+ }
+
+ interval = Math.max(-1, interval);
+
+ final var copied = textData.copy(textData.getName());
+ copied.setTextUpdateInterval(interval);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.UPDATE_TEXT_INTERVAL)) {
+ return false;
+ }
+
+ if (copied.getTextUpdateInterval() == textData.getTextUpdateInterval()) {
+ MessageHelper.warning(player, "This hologram already has this text update interval");
+ return false;
+ }
+
+ textData.setTextUpdateInterval(copied.getTextUpdateInterval());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed the text update interval");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/VisibilityCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/VisibilityCMD.java
new file mode 100644
index 00000000..41657fd0
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/VisibilityCMD.java
@@ -0,0 +1,56 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import de.oliver.fancyholograms.api.data.property.Visibility;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+public class VisibilityCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return Arrays.stream(
+ Visibility.values()
+ ).map(Objects::toString).toList();
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.visibility"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ final var optionalVisibility = Visibility.byString(args[3]);
+ if (hologram == null || optionalVisibility.isEmpty()) {
+ return false;
+ }
+ final var visibility = optionalVisibility.get();
+
+ final var copied = hologram.getData().copy(hologram.getData().getName());
+ copied.setVisibility(visibility);
+
+ if (hologram.getData().getVisibility() == copied.getVisibility()) {
+ MessageHelper.warning(player, "This hologram already has visibility set to " + visibility);
+ return false;
+ }
+
+ hologram.getData().setVisibility(copied.getVisibility());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed visibility to " + visibility);
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/VisibilityDistanceCMD.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/VisibilityDistanceCMD.java
new file mode 100644
index 00000000..c78a5001
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/commands/hologram/VisibilityDistanceCMD.java
@@ -0,0 +1,68 @@
+package de.oliver.fancyholograms.commands.hologram;
+
+import com.google.common.primitives.Ints;
+import de.oliver.fancyholograms.api.events.HologramUpdateEvent;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.commands.HologramCMD;
+import de.oliver.fancyholograms.commands.Subcommand;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.MessageHelper;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class VisibilityDistanceCMD implements Subcommand {
+
+ @Override
+ public List tabcompletion(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+ return null;
+ }
+
+ @Override
+ public boolean run(@NotNull CommandSender player, @Nullable Hologram hologram, @NotNull String[] args) {
+
+ if (!(player.hasPermission("fancyholograms.hologram.edit.visibility_distance"))) {
+ MessageHelper.error(player, "You don't have the required permission to edit a hologram");
+ return false;
+ }
+
+ var visibilityDistance = Ints.tryParse(args[3]);
+
+ if (visibilityDistance == null) {
+ MessageHelper.error(player, "Could not parse visibility distance");
+ return false;
+ }
+
+ if (visibilityDistance <= 0) {
+ visibilityDistance = -1;
+ }
+
+ if (Ints.compare(visibilityDistance, hologram.getData().getVisibilityDistance()) == 0) {
+ MessageHelper.warning(player, "This hologram already has this visibility distance");
+ return false;
+ }
+
+ final var copied = hologram.getData().copy(hologram.getData().getName());
+ copied.setVisibilityDistance(visibilityDistance);
+
+ if (!HologramCMD.callModificationEvent(hologram, player, copied, HologramUpdateEvent.HologramModification.UPDATE_VISIBILITY_DISTANCE)) {
+ return false;
+ }
+
+ if (Ints.compare(copied.getVisibilityDistance(), hologram.getData().getVisibilityDistance()) == 0) {
+ MessageHelper.warning(player, "This hologram already has this visibility distance");
+ return false;
+ }
+
+ hologram.getData().setVisibilityDistance(copied.getVisibilityDistance());
+
+ if (FancyHologramsPlugin.get().getHologramConfiguration().isSaveOnChangedEnabled()) {
+ FancyHologramsPlugin.get().getStorage().save(hologram.getData());
+ }
+
+ MessageHelper.success(player, "Changed visibility distance");
+ return true;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/config/FHConfiguration.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/config/FHConfiguration.java
new file mode 100644
index 00000000..24723983
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/config/FHConfiguration.java
@@ -0,0 +1,126 @@
+package de.oliver.fancyholograms.config;
+
+import de.oliver.fancyholograms.api.FancyHolograms;
+import de.oliver.fancyholograms.api.HologramConfiguration;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.ConfigHelper;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+/**
+ * The FancyHologramsConfig class is responsible for managing the configuration of the FancyHolograms plugin.
+ * It handles loading and saving hologram data, as well as providing access to various configuration settings.
+ */
+public final class FHConfiguration implements HologramConfiguration {
+
+ /**
+ * Indicates whether version notifications are muted.
+ */
+ private boolean versionNotifsMuted;
+
+ /**
+ * Indicates whether autosave is enabled.
+ */
+ private boolean autosaveEnabled;
+
+ /**
+ * The interval at which autosave is performed.
+ */
+ private int autosaveInterval;
+
+ /**
+ * Indicates whether the plugin should save holograms when they are changed.
+ */
+ private boolean saveOnChangedEnabled;
+
+ /**
+ * The default visibility distance for holograms.
+ */
+ private int defaultVisibilityDistance;
+
+ /**
+ * Indicates whether commands should be registered.
+ *
+ * This is useful for users who want to use the plugin's API only.
+ */
+ private boolean registerCommands;
+
+ /**
+ * The log level for the plugin.
+ */
+ private String logLevel;
+
+ @Override
+ public void reload(@NotNull FancyHolograms plugin) {
+ FancyHologramsPlugin pluginImpl = (FancyHologramsPlugin) plugin;
+ pluginImpl.reloadConfig();
+
+ final var config = pluginImpl.getConfig();
+
+ versionNotifsMuted = (boolean) ConfigHelper.getOrDefault(config, "mute_version_notification", false);
+ config.setInlineComments("mute_version_notification", List.of("Whether version notifications are muted."));
+
+ autosaveEnabled = (boolean) ConfigHelper.getOrDefault(config, "enable_autosave", true);
+ config.setInlineComments("enable_autosave", List.of("Whether autosave is enabled."));
+
+ autosaveInterval = (int) ConfigHelper.getOrDefault(config, "autosave_interval", 15);
+ config.setInlineComments("autosave_interval", List.of("The interval at which autosave is performed in minutes."));
+
+ saveOnChangedEnabled = (boolean) ConfigHelper.getOrDefault(config, "save_on_changed", true);
+ config.setInlineComments("save_on_changed", List.of("Whether the plugin should save holograms when they are changed."));
+
+ defaultVisibilityDistance = (int) ConfigHelper.getOrDefault(config, "visibility_distance", 20);
+ config.setInlineComments("visibility_distance", List.of("The default visibility distance for holograms."));
+
+ registerCommands = (boolean) ConfigHelper.getOrDefault(config, "register_commands", true);
+ config.setInlineComments("register_commands", List.of("Whether the plugin should register its commands."));
+
+ config.set("report_errors_to_sentry", null);
+ config.setInlineComments("report_errors_to_sentry", null);
+
+ config.setInlineComments("log_level", List.of("The log level for the plugin (DEBUG, INFO, WARN, ERROR)."));
+ logLevel = (String) ConfigHelper.getOrDefault(config, "log_level", "INFO");
+
+ if (pluginImpl.isEnabled()) {
+ plugin.getHologramThread().submit(pluginImpl::saveConfig);
+ } else {
+ // Can't dispatch task if plugin is disabled
+ pluginImpl.saveConfig();
+ }
+ }
+
+ @Override
+ public boolean areVersionNotificationsMuted() {
+ return versionNotifsMuted;
+ }
+
+ @Override
+ public boolean isAutosaveEnabled() {
+ return autosaveEnabled;
+ }
+
+ @Override
+ public int getAutosaveInterval() {
+ return autosaveInterval;
+ }
+
+ @Override
+ public boolean isSaveOnChangedEnabled() {
+ return saveOnChangedEnabled;
+ }
+
+ @Override
+ public int getDefaultVisibilityDistance() {
+ return defaultVisibilityDistance;
+ }
+
+ @Override
+ public boolean isRegisterCommands() {
+ return registerCommands;
+ }
+
+ public String getLogLevel() {
+ return logLevel;
+ }
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/config/FHFeatureFlags.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/config/FHFeatureFlags.java
new file mode 100644
index 00000000..f3fdd363
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/config/FHFeatureFlags.java
@@ -0,0 +1,19 @@
+package de.oliver.fancyholograms.config;
+
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancylib.featureFlags.FeatureFlag;
+import de.oliver.fancylib.featureFlags.FeatureFlagConfig;
+
+public class FHFeatureFlags {
+
+ public static final FeatureFlag DISABLE_HOLOGRAMS_FOR_BEDROCK_PLAYERS = new FeatureFlag("disable-holograms-for-bedrock-players", "Do not show holograms to bedrock players", false);
+ public static final FeatureFlag DISABLE_HOLOGRAMS_FOR_OLD_CLIENTS = new FeatureFlag("disable-holograms-for-old-clients", "Do not show holograms to clients with a version older than 1.19.4", false);
+
+ public static void load() {
+ FeatureFlagConfig config = new FeatureFlagConfig(FancyHologramsPlugin.get());
+ config.addFeatureFlag(DISABLE_HOLOGRAMS_FOR_BEDROCK_PLAYERS);
+ config.addFeatureFlag(DISABLE_HOLOGRAMS_FOR_OLD_CLIENTS);
+ config.load();
+ }
+
+}
diff --git a/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/controller/HologramControllerImpl.java b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/controller/HologramControllerImpl.java
new file mode 100644
index 00000000..6777c697
--- /dev/null
+++ b/plugins/fancyholograms/src/main/java/de/oliver/fancyholograms/controller/HologramControllerImpl.java
@@ -0,0 +1,168 @@
+package de.oliver.fancyholograms.controller;
+
+import com.google.common.cache.CacheBuilder;
+import de.oliver.fancyholograms.api.HologramController;
+import de.oliver.fancyholograms.api.data.DisplayHologramData;
+import de.oliver.fancyholograms.api.data.HologramData;
+import de.oliver.fancyholograms.api.data.TextHologramData;
+import de.oliver.fancyholograms.api.hologram.Hologram;
+import de.oliver.fancyholograms.main.FancyHologramsPlugin;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.joml.Vector3f;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+public class HologramControllerImpl implements HologramController {
+
+ @Override
+ public void showHologramTo(@NotNull final Hologram hologram, @NotNull final Player... players) {
+ for (Player player : players) {
+ boolean isVisible = hologram.isViewer(player);
+ boolean shouldSee = shouldSeeHologram(hologram, player);
+
+ if (isVisible || !shouldSee) {
+ continue;
+ }
+
+ hologram.spawnTo(player);
+ }
+ }
+
+ @Override
+ public void hideHologramFrom(@NotNull final Hologram hologram, @NotNull final Player... players) {
+ for (Player player : players) {
+ boolean isVisible = hologram.isViewer(player);
+ boolean shouldSee = shouldSeeHologram(hologram, player);
+
+ if (!isVisible || shouldSee) {
+ continue;
+ }
+
+ hologram.despawnFrom(player);
+ }
+ }
+
+ @Override
+ public boolean shouldSeeHologram(@NotNull final Hologram hologram, @NotNull final Player player) {
+ if (!meetsVisibilityConditions(hologram, player)) {
+ return false;
+ }
+
+ return isWithinVisibilityDistance(hologram, player);
+ }
+
+ @Override
+ public void refreshHologram(@NotNull final Hologram hologram, @NotNull final Player... players) {
+ hideHologramFrom(hologram, players);
+ showHologramTo(hologram, players);
+ }
+
+ private boolean meetsVisibilityConditions(@NotNull final Hologram hologram, @NotNull final Player player) {
+ return hologram.getData().getVisibility().canSee(player, hologram);
+ }
+
+ private boolean isWithinVisibilityDistance(@NotNull final Hologram hologram, @NotNull final Player player) {
+ final var location = hologram.getData().getLocation();
+ if (!location.getWorld().equals(player.getWorld())) {
+ return false;
+ }
+
+ int visibilityDistance = hologram.getData().getVisibilityDistance();
+ double distanceSquared = location.distanceSquared(player.getLocation());
+
+ return distanceSquared <= visibilityDistance * visibilityDistance;
+ }
+
+ public void initRefreshTask() {
+ FancyHologramsPlugin.get().getHologramThread().scheduleWithFixedDelay(() -> {
+ for (Hologram hologram : FancyHologramsPlugin.get().getRegistry().getAll()) {
+ refreshHologram(hologram, Bukkit.getOnlinePlayers().toArray(new Player[0]));
+ }
+ }, 0, 1, TimeUnit.SECONDS);
+ }
+
+ public void initUpdateTask() {
+ final var updateTimes = CacheBuilder.newBuilder()
+ .expireAfterAccess(Duration.ofMinutes(5))
+ .