+
+## Features
+
+With this plugin you can create NPCs with customizable properties like:
+
+- **Type** (Cow, Pig, Player, etc.)
+- **Skin** (from username, texture URL or placeholder)
+- **Glowing** (in all colors)
+- **Attributes** (pose, visibility, variant, etc.)
+- **Equipment** (eg. holding a diamond sword and wearing leather armor)
+- **Interactions** (execute commands, send messages etc.)
+- ...and much more!
+
+Check out **[images section](#images)** down below.
+
+
+
+## Installation
+
+Paper **1.19.4** - **1.21.5** with **Java 21** (or higher) is required. Plugin should also work on **Paper** forks.
+
+**Spigot** is **not** supported.
+
+### Download (Stable)
+
+- **[Hangar](https://hangar.papermc.io/Oliver/FancyNpcs)**
+- **[Modrinth](https://modrinth.com/plugin/fancynpcs)**
+- **[GitHub Releases](https://github.com/FancyMcPlugins/FancyNpcs/releases)**
+
+### Download (Development Builds)
+
+- **[Jenkins CI](https://jenkins.fancyplugins.de/job/FancyNpcs/)**
+- **[FancyPlugins Website](https://fancyplugins.de/FancyNpcs/download)**
+
+
+
+## Documentation
+
+Official documentation is hosted **[here](https://fancyplugins.de/docs/fancynpcs.html)**. Quick reference:
+
+- **[Getting Started](https://fancyplugins.de/docs/fn-getting-started.html)**
+- **[Command Reference](https://fancyplugins.de/docs/fn-commands.html)**
+- **[Using API](https://fancyplugins.de/docs/fn-api.html)**
+
+**Have more questions?** Feel free to ask them on our **[Discord](https://discord.gg/ZUgYCEJUEx)** server.
+
+
+
+## Developer API
+
+More information can be found in **[Documentation](https://fancyplugins.de/docs/fn-api.html)** and **[Javadocs](https://repo.fancyplugins.de/javadoc/releases/de/oliver/FancyNpcs/latest)**.
+
+### Maven
+
+```xml
+
+
+ fancyplugins-releases
+ FancyPlugins Repository
+ https://repo.fancyplugins.de/releases
+
+```
+
+```xml
+
+
+ de.oliver
+ FancyNpcs
+ [VERSION]
+ provided
+
+```
+
+### Gradle
+
+```groovy
+repositories {
+ maven("https://repo.fancyplugins.de/releases")
+}
+
+dependencies {
+ compileOnly("de.oliver:FancyNpcs:[VERSION]")
+}
+```
+
+
+
+## Building
+
+Follow these steps to build the plugin locally:
+
+```shell
+# Cloning repository.
+$ git clone https://github.com/FancyMcPlugins/FancyNpcs.git
+# Entering cloned repository.
+$ cd FancyNpcs
+# Compiling and building artifacts.
+$ gradlew shadowJar
+# Once successfully built, plugin .jar can be found in /build/libs directory.
+```
+
+
+
+## Images
+
+Images showcasing the plugin, sent to us by our community.
+
+
+Provided by [Explorer's Eden](https://explorerseden.eu/)
+
+
+Provided by [Explorer's Eden](https://explorerseden.eu/)
+
+
+Provided by [Explorer's Eden](https://explorerseden.eu/)
+
+
+Provided by [Beacon's Quest](https://www.beaconsquest.net/)
+
+
+Provided by [@OliverSchlueter](https://github.com/OliverSchlueter)
+
+
+Provided by [@OliverSchlueter](https://github.com/OliverSchlueter)
+
+
+Provided by [@Grabsky](https://github.com/Grabsky)
diff --git a/plugins/fancynpcs/api/build.gradle.kts b/plugins/fancynpcs/api/build.gradle.kts
new file mode 100644
index 00000000..b656c8c2
--- /dev/null
+++ b/plugins/fancynpcs/api/build.gradle.kts
@@ -0,0 +1,71 @@
+plugins {
+ id("java-library")
+ id("maven-publish")
+ id("com.gradleup.shadow")
+}
+
+val minecraftVersion = "1.19.4"
+
+dependencies {
+ compileOnly("io.papermc.paper:paper-api:$minecraftVersion-R0.1-SNAPSHOT")
+
+ compileOnly("de.oliver:FancyLib:37")
+ compileOnly("de.oliver.FancyAnalytics:logger:0.0.6")
+
+ implementation("org.lushplugins:ChatColorHandler:5.1.3")
+}
+
+tasks {
+ shadowJar {
+ archiveClassifier.set("")
+
+ relocate("org.lushplugins.chatcolorhandler", "de.oliver.fancynpcs.libs.chatcolorhandler")
+ }
+
+ 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 = rootProject.group.toString()
+ artifactId = rootProject.name
+ version = rootProject.version.toString()
+ from(project.components["java"])
+ }
+ }
+ }
+
+ java {
+ withSourcesJar()
+ withJavadocJar()
+ }
+
+ javadoc {
+ options.encoding = Charsets.UTF_8.name()
+ }
+
+ compileJava {
+ options.encoding = Charsets.UTF_8.name()
+ options.release = 17
+
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/AttributeManager.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/AttributeManager.java
new file mode 100644
index 00000000..6a8af52f
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/AttributeManager.java
@@ -0,0 +1,16 @@
+package de.oliver.fancynpcs.api;
+
+import org.bukkit.entity.EntityType;
+
+import java.util.List;
+
+public interface AttributeManager {
+
+ NpcAttribute getAttributeByName(EntityType type, String name);
+
+ List getAllAttributes();
+
+ List getAllAttributesForEntityType(EntityType type);
+
+ void registerAttribute(NpcAttribute attribute);
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/FancyNpcsConfig.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/FancyNpcsConfig.java
new file mode 100644
index 00000000..930b1518
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/FancyNpcsConfig.java
@@ -0,0 +1,36 @@
+package de.oliver.fancynpcs.api;
+
+import java.util.List;
+import java.util.Map;
+
+public interface FancyNpcsConfig {
+
+ boolean isSkipInvisibleNpcs();
+
+ boolean isInteractionCooldownMessageDisabled();
+
+ boolean isMuteVersionNotification();
+
+ boolean isEnableAutoSave();
+
+ int getAutoSaveInterval();
+
+ int getNpcUpdateInterval();
+
+ int getNpcUpdateVisibilityInterval();
+
+ int getTurnToPlayerDistance();
+
+ boolean isTurnToPlayerResetToInitialDirection();
+
+ int getVisibilityDistance();
+
+ int getRemoveNpcsFromPlayerlistDelay();
+
+ String getMineSkinApiKey();
+
+ List getBlockedCommands();
+
+ Map getMaxNpcsPerPermission();
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/FancyNpcsPlugin.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/FancyNpcsPlugin.java
new file mode 100644
index 00000000..a582a1a9
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/FancyNpcsPlugin.java
@@ -0,0 +1,56 @@
+package de.oliver.fancynpcs.api;
+
+import de.oliver.fancyanalytics.logger.ExtendedFancyLogger;
+import de.oliver.fancylib.serverSoftware.schedulers.FancyScheduler;
+import de.oliver.fancylib.translations.Translator;
+import de.oliver.fancynpcs.api.actions.ActionManager;
+import de.oliver.fancynpcs.api.skins.SkinManager;
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.PluginManager;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Function;
+
+public interface FancyNpcsPlugin {
+
+ static FancyNpcsPlugin get() {
+ PluginManager pluginManager = Bukkit.getPluginManager();
+
+ if (pluginManager.isPluginEnabled("FancyNpcs")) {
+ return (FancyNpcsPlugin) pluginManager.getPlugin("FancyNpcs");
+ }
+
+ throw new NullPointerException("Plugin is not enabled");
+ }
+
+ JavaPlugin getPlugin();
+
+ ExtendedFancyLogger getFancyLogger();
+
+ ScheduledExecutorService getNpcThread();
+
+ /**
+ * Creates a new thread with the given name and runnable.
+ * Warning: Do not use this method, it is for internal use only.
+ */
+ @ApiStatus.Internal
+ Thread newThread(String name, Runnable runnable);
+
+ FancyScheduler getScheduler();
+
+ Function getNpcAdapter();
+
+ FancyNpcsConfig getFancyNpcConfig();
+
+ NpcManager getNpcManager();
+
+ AttributeManager getAttributeManager();
+
+ ActionManager getActionManager();
+
+ SkinManager getSkinManager();
+
+ Translator getTranslator();
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/Npc.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/Npc.java
new file mode 100644
index 00000000..62ce4a5d
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/Npc.java
@@ -0,0 +1,227 @@
+package de.oliver.fancynpcs.api;
+
+import de.oliver.fancylib.RandomUtils;
+import de.oliver.fancylib.translations.Translator;
+import de.oliver.fancynpcs.api.actions.ActionTrigger;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutor;
+import de.oliver.fancynpcs.api.events.NpcInteractEvent;
+import de.oliver.fancynpcs.api.utils.Interval;
+import de.oliver.fancynpcs.api.utils.Interval.Unit;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.Location;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+public abstract class Npc {
+
+ private static final NpcAttribute INVISIBLE_ATTRIBUTE = FancyNpcsPlugin.get().getAttributeManager().getAttributeByName(EntityType.PLAYER, "invisible");
+ private static final char[] localNameChars = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'k', 'l', 'm', 'n', 'o', 'r'};
+ protected final Map isTeamCreated = new ConcurrentHashMap<>();
+ protected final Map isVisibleForPlayer = new ConcurrentHashMap<>();
+ protected final Map isLookingAtPlayer = new ConcurrentHashMap<>();
+ protected final Map lastPlayerInteraction = new ConcurrentHashMap<>();
+ private final Translator translator = FancyNpcsPlugin.get().getTranslator();
+ protected NpcData data;
+ protected boolean saveToFile;
+
+ public Npc(NpcData data) {
+ this.data = data;
+ this.saveToFile = true;
+ }
+
+ protected String generateLocalName() {
+ String localName = "";
+ for (int i = 0; i < 8; i++) {
+ localName += "&" + localNameChars[(int) RandomUtils.randomInRange(0, localNameChars.length)];
+ }
+
+ localName = ChatColor.translateAlternateColorCodes('&', localName);
+
+ return localName;
+ }
+
+ public abstract void create();
+
+ public abstract void spawn(Player player);
+
+ public void spawnForAll() {
+ FancyNpcsPlugin.get().getNpcThread().submit(() -> {
+ for (Player onlinePlayer : Bukkit.getOnlinePlayers()) {
+ spawn(onlinePlayer);
+ }
+ });
+ }
+
+ public abstract void remove(Player player);
+
+ public void removeForAll() {
+ for (Player onlinePlayer : Bukkit.getOnlinePlayers()) {
+ remove(onlinePlayer);
+ }
+ }
+
+ /**
+ * Checks if the NPC should be visible for the player.
+ *
+ * @param player The player to check for.
+ * @return True if the NPC should be visible for the player, otherwise false.
+ */
+ protected boolean shouldBeVisible(Player player) {
+ int visibilityDistance = (data.getVisibilityDistance() > -1) ? data.getVisibilityDistance() : FancyNpcsPlugin.get().getFancyNpcConfig().getVisibilityDistance();
+
+ if (visibilityDistance == 0) {
+ return false;
+ }
+
+ if (!data.isSpawnEntity()) {
+ return false;
+ }
+
+ if (data.getLocation() == null) {
+ return false;
+ }
+
+ if (player.getLocation().getWorld() != data.getLocation().getWorld()) {
+ return false;
+ }
+
+ if (visibilityDistance != Integer.MAX_VALUE && data.getLocation().distanceSquared(player.getLocation()) > visibilityDistance * visibilityDistance) {
+ return false;
+ }
+
+ if (FancyNpcsPlugin.get().getFancyNpcConfig().isSkipInvisibleNpcs() && data.getAttributes().getOrDefault(INVISIBLE_ATTRIBUTE, "false").equalsIgnoreCase("true") && !data.isGlowing() && data.getEquipment().isEmpty()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public void checkAndUpdateVisibility(Player player) {
+ FancyNpcsPlugin.get().getNpcThread().submit(() -> {
+ boolean shouldBeVisible = shouldBeVisible(player);
+ boolean wasVisible = isVisibleForPlayer.getOrDefault(player.getUniqueId(), false);
+
+ if (shouldBeVisible && !wasVisible) {
+ spawn(player);
+ } else if (!shouldBeVisible && wasVisible) {
+ remove(player);
+ }
+ });
+ }
+
+ public abstract void lookAt(Player player, Location location);
+
+ public abstract void update(Player player);
+
+ public void updateForAll() {
+ for (Player onlinePlayer : Bukkit.getOnlinePlayers()) {
+ update(onlinePlayer);
+ }
+ }
+
+ public abstract void move(Player player, boolean swingArm);
+
+ public void move(Player player) {
+ move(player, true);
+ }
+
+ public void moveForAll(boolean swingArm) {
+ for (Player onlinePlayer : Bukkit.getOnlinePlayers()) {
+ move(onlinePlayer, swingArm);
+ }
+ }
+
+ public void moveForAll() {
+ moveForAll(true);
+ }
+
+ public void interact(Player player) {
+ interact(player, ActionTrigger.CUSTOM);
+ }
+
+ public void interact(Player player, ActionTrigger actionTrigger) {
+ if (data.getInteractionCooldown() > 0) {
+ final long interactionCooldownMillis = (long) (data.getInteractionCooldown() * 1000);
+ final long lastInteractionMillis = lastPlayerInteraction.getOrDefault(player.getUniqueId(), 0L);
+ final Interval interactionCooldownLeft = Interval.between(lastInteractionMillis + interactionCooldownMillis, System.currentTimeMillis(), Unit.MILLISECONDS);
+ if (interactionCooldownLeft.as(Unit.MILLISECONDS) > 0) {
+
+ if (!FancyNpcsPlugin.get().getFancyNpcConfig().isInteractionCooldownMessageDisabled()) {
+ translator.translate("interaction_on_cooldown").replace("time", interactionCooldownLeft.toString()).send(player);
+ }
+
+ return;
+ }
+ lastPlayerInteraction.put(player.getUniqueId(), System.currentTimeMillis());
+ }
+
+ List actions = data.getActions(actionTrigger);
+ NpcInteractEvent npcInteractEvent = new NpcInteractEvent(this, data.getOnClick(), actions, player, actionTrigger);
+ npcInteractEvent.callEvent();
+
+ if (npcInteractEvent.isCancelled()) {
+ return;
+ }
+
+ // onClick
+ if (data.getOnClick() != null) {
+ data.getOnClick().accept(player);
+ }
+
+ // actions
+ ActionExecutor.execute(actionTrigger, this, player);
+
+ if (actionTrigger == ActionTrigger.LEFT_CLICK || actionTrigger == ActionTrigger.RIGHT_CLICK) {
+ ActionExecutor.execute(ActionTrigger.ANY_CLICK, this, player);
+ }
+ }
+
+ protected abstract void refreshEntityData(Player serverPlayer);
+
+ public abstract int getEntityId();
+
+ public NpcData getData() {
+ return data;
+ }
+
+ public abstract float getEyeHeight();
+
+ public Map getIsTeamCreated() {
+ return isTeamCreated;
+ }
+
+ public Map getIsVisibleForPlayer() {
+ return isVisibleForPlayer;
+ }
+
+ public Map getIsLookingAtPlayer() {
+ return isLookingAtPlayer;
+ }
+
+ public Map getLastPlayerInteraction() {
+ return lastPlayerInteraction;
+ }
+
+ public boolean isDirty() {
+ return data.isDirty();
+ }
+
+ public void setDirty(boolean dirty) {
+ data.setDirty(dirty);
+ }
+
+ public boolean isSaveToFile() {
+ return saveToFile;
+ }
+
+ public void setSaveToFile(boolean saveToFile) {
+ this.saveToFile = saveToFile;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcAttribute.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcAttribute.java
new file mode 100644
index 00000000..5981ecc3
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcAttribute.java
@@ -0,0 +1,52 @@
+package de.oliver.fancynpcs.api;
+
+import org.bukkit.entity.EntityType;
+
+import java.util.List;
+import java.util.function.BiConsumer;
+
+public class NpcAttribute {
+
+ private final String name;
+ private final List possibleValues;
+ private final List types;
+ private final BiConsumer applyFunc; // npc, value
+
+ public NpcAttribute(String name, List possibleValues, List types, BiConsumer applyFunc) {
+ this.name = name;
+ this.possibleValues = possibleValues;
+ this.types = types;
+ this.applyFunc = applyFunc;
+ }
+
+ public boolean isValidValue(String value) {
+ if (possibleValues.isEmpty()) {
+ return true;
+ }
+
+ for (String pv : possibleValues) {
+ if (pv.equalsIgnoreCase(value)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public void apply(Npc npc, String value) {
+ applyFunc.accept(npc, value);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public List getPossibleValues() {
+ return possibleValues;
+ }
+
+ public List getTypes() {
+ return types;
+ }
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcData.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcData.java
new file mode 100644
index 00000000..786358f3
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcData.java
@@ -0,0 +1,384 @@
+package de.oliver.fancynpcs.api;
+
+import de.oliver.fancynpcs.api.actions.ActionTrigger;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.skins.SkinData;
+import de.oliver.fancynpcs.api.utils.NpcEquipmentSlot;
+import net.kyori.adventure.text.format.NamedTextColor;
+import org.bukkit.Location;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+
+public class NpcData {
+
+ private final String id;
+ private final String name;
+ private final UUID creator;
+ private String displayName;
+ private SkinData skin;
+ private boolean mirrorSkin;
+ private Location location;
+ private boolean showInTab;
+ private boolean spawnEntity;
+ private boolean collidable;
+ private boolean glowing;
+ private NamedTextColor glowingColor;
+ private EntityType type;
+ private Map equipment;
+ private Consumer onClick;
+ private Map> actions;
+ private boolean turnToPlayer;
+ private float interactionCooldown;
+ private float scale;
+ private int visibilityDistance;
+ private Map attributes;
+ private boolean isDirty;
+
+ public NpcData(
+ String id,
+ String name,
+ UUID creator,
+ String displayName,
+ SkinData skin,
+ Location location,
+ boolean showInTab,
+ boolean spawnEntity,
+ boolean collidable,
+ boolean glowing,
+ NamedTextColor glowingColor,
+ EntityType type,
+ Map equipment,
+ boolean turnToPlayer,
+ Consumer onClick,
+ Map> actions,
+ float interactionCooldown,
+ float scale,
+ int visibilityDistance,
+ Map attributes,
+ boolean mirrorSkin
+ ) {
+ this.id = id;
+ this.name = name;
+ this.creator = creator;
+ this.displayName = displayName;
+ this.skin = skin;
+ this.location = location;
+ this.showInTab = showInTab;
+ this.spawnEntity = spawnEntity;
+ this.collidable = collidable;
+ this.glowing = glowing;
+ this.glowingColor = glowingColor;
+ this.type = type;
+ this.equipment = equipment;
+ this.onClick = onClick;
+ this.actions = actions;
+ this.turnToPlayer = turnToPlayer;
+ this.interactionCooldown = interactionCooldown;
+ this.scale = scale;
+ this.visibilityDistance = visibilityDistance;
+ this.attributes = attributes;
+ this.mirrorSkin = mirrorSkin;
+ this.isDirty = true;
+ }
+
+ /**
+ * Creates a default npc with random id
+ */
+ public NpcData(String name, UUID creator, Location location) {
+ this.id = UUID.randomUUID().toString();
+ this.name = name;
+ this.creator = creator;
+ this.location = location;
+ this.displayName = name;
+ this.type = EntityType.PLAYER;
+ this.showInTab = false;
+ this.spawnEntity = true;
+ this.collidable = true;
+ this.glowing = false;
+ this.glowingColor = NamedTextColor.WHITE;
+ this.onClick = p -> {
+ };
+ this.actions = new ConcurrentHashMap<>();
+ this.turnToPlayer = false;
+ this.interactionCooldown = 0;
+ this.scale = 1;
+ this.visibilityDistance = -1;
+ this.equipment = new ConcurrentHashMap<>();
+ this.attributes = new ConcurrentHashMap<>();
+ this.mirrorSkin = false;
+ this.isDirty = true;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public UUID getCreator() {
+ return creator == null ? UUID.fromString("00000000-0000-0000-0000-000000000000") : creator;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public NpcData setDisplayName(String displayName) {
+ this.displayName = displayName;
+ isDirty = true;
+ return this;
+ }
+
+ public SkinData getSkinData() {
+ return skin;
+ }
+
+ /**
+ * Sets the skin data of the npc
+ * Use this method, if you have a loaded skin data object (with texture and signature), otherwise use {@link #setSkin(String, SkinData.SkinVariant)}
+ *
+ * @param skinData the skin data
+ */
+ public NpcData setSkinData(SkinData skinData) {
+ this.skin = skinData;
+ isDirty = true;
+ return this;
+ }
+
+ /**
+ * Loads the skin data and sets it as the skin of the npc
+ *
+ * @param skin a valid UUID, username, URL or file path
+ * @param variant the skin variant
+ */
+ public NpcData setSkin(String skin, SkinData.SkinVariant variant) {
+ SkinData data = FancyNpcsPlugin.get().getSkinManager().getByIdentifier(skin, variant);
+ return setSkinData(data);
+ }
+
+ /**
+ * Loads the skin data and sets it as the skin of the npc
+ *
+ * @param skin a valid UUID, username, URL or file path
+ */
+ public NpcData setSkin(String skin) {
+ return setSkin(skin, SkinData.SkinVariant.AUTO);
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ public NpcData setLocation(Location location) {
+ this.location = location;
+ isDirty = true;
+ return this;
+ }
+
+ public boolean isShowInTab() {
+ return showInTab;
+ }
+
+ public NpcData setShowInTab(boolean showInTab) {
+ this.showInTab = showInTab;
+ isDirty = true;
+ return this;
+ }
+
+ public boolean isSpawnEntity() {
+ return spawnEntity;
+ }
+
+ public NpcData setSpawnEntity(boolean spawnEntity) {
+ this.spawnEntity = spawnEntity;
+ isDirty = true;
+ return this;
+ }
+
+ public boolean isCollidable() {
+ return collidable;
+ }
+
+ public NpcData setCollidable(boolean collidable) {
+ this.collidable = collidable;
+ isDirty = true;
+ return this;
+ }
+
+ public boolean isGlowing() {
+ return glowing;
+ }
+
+ public NpcData setGlowing(boolean glowing) {
+ this.glowing = glowing;
+ isDirty = true;
+ return this;
+ }
+
+ public NamedTextColor getGlowingColor() {
+ return glowingColor;
+ }
+
+ public NpcData setGlowingColor(NamedTextColor glowingColor) {
+ this.glowingColor = glowingColor;
+ isDirty = true;
+ return this;
+ }
+
+ public EntityType getType() {
+ return type;
+ }
+
+ public NpcData setType(EntityType type) {
+ this.type = type;
+ attributes.clear();
+ isDirty = true;
+ return this;
+ }
+
+ public Map getEquipment() {
+ return equipment;
+ }
+
+ public NpcData setEquipment(Map equipment) {
+ this.equipment = equipment;
+ isDirty = true;
+ return this;
+ }
+
+ public NpcData addEquipment(NpcEquipmentSlot slot, ItemStack item) {
+ equipment.put(slot, item);
+ isDirty = true;
+ return this;
+ }
+
+ public Consumer getOnClick() {
+ return onClick;
+ }
+
+ public NpcData setOnClick(Consumer onClick) {
+ this.onClick = onClick;
+ isDirty = true;
+ return this;
+ }
+
+ public Map> getActions() {
+ return actions;
+ }
+
+ public NpcData setActions(Map> actions) {
+ this.actions = actions;
+ isDirty = true;
+ return this;
+ }
+
+ public List getActions(ActionTrigger trigger) {
+ return actions.getOrDefault(trigger, new ArrayList<>());
+ }
+
+ public NpcData setActions(ActionTrigger trigger, List actions) {
+ this.actions.put(trigger, actions);
+ isDirty = true;
+ return this;
+ }
+
+ public NpcData addAction(ActionTrigger trigger, int order, NpcAction action, String value) {
+ List a = actions.getOrDefault(trigger, new ArrayList<>());
+ a.add(new NpcAction.NpcActionData(order, action, value));
+ actions.put(trigger, a);
+
+ isDirty = true;
+ return this;
+ }
+
+ public NpcData removeAction(ActionTrigger trigger, NpcAction action) {
+ List a = actions.getOrDefault(trigger, new ArrayList<>());
+ a.removeIf(ad -> ad.action().equals(action));
+ actions.put(trigger, a);
+
+ isDirty = true;
+ return this;
+ }
+
+ public boolean isTurnToPlayer() {
+ return turnToPlayer;
+ }
+
+ public NpcData setTurnToPlayer(boolean turnToPlayer) {
+ this.turnToPlayer = turnToPlayer;
+ isDirty = true;
+ return this;
+ }
+
+ public float getInteractionCooldown() {
+ return interactionCooldown;
+ }
+
+ public NpcData setInteractionCooldown(float interactionCooldown) {
+ this.interactionCooldown = interactionCooldown;
+ return this;
+ }
+
+ public float getScale() {
+ return scale;
+ }
+
+ public NpcData setScale(float scale) {
+ this.scale = scale;
+ isDirty = true;
+ return this;
+ }
+
+ public int getVisibilityDistance() {
+ return visibilityDistance;
+ }
+
+ public NpcData setVisibilityDistance(int visibilityDistance) {
+ this.visibilityDistance = visibilityDistance;
+ isDirty = true;
+ return this;
+ }
+
+ public Map getAttributes() {
+ return attributes;
+ }
+
+ public void addAttribute(NpcAttribute attribute, String value) {
+ attributes.put(attribute, value);
+ isDirty = true;
+ }
+
+ public void applyAllAttributes(Npc npc) {
+ for (NpcAttribute attribute : attributes.keySet()) {
+ attribute.apply(npc, attributes.get(attribute));
+ }
+ }
+
+ public boolean isMirrorSkin() {
+ return mirrorSkin;
+ }
+
+ public NpcData setMirrorSkin(boolean mirrorSkin) {
+ this.mirrorSkin = mirrorSkin;
+ isDirty = true;
+ return this;
+ }
+
+ public boolean isDirty() {
+ return isDirty;
+ }
+
+ public void setDirty(boolean dirty) {
+ isDirty = dirty;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcManager.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcManager.java
new file mode 100644
index 00000000..2cdf433d
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/NpcManager.java
@@ -0,0 +1,31 @@
+package de.oliver.fancynpcs.api;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.Collection;
+import java.util.UUID;
+
+public interface NpcManager {
+
+ void registerNpc(Npc npc);
+
+ void removeNpc(Npc npc);
+
+ @ApiStatus.Internal
+ Npc getNpc(int entityId);
+
+ Npc getNpc(String name);
+
+ Npc getNpcById(String id);
+
+ Npc getNpc(String name, UUID creator);
+
+ Collection getAllNpcs();
+
+ void saveNpcs(boolean force);
+
+ void loadNpcs();
+
+ void reloadNpcs();
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/ActionManager.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/ActionManager.java
new file mode 100644
index 00000000..a6bdcc4a
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/ActionManager.java
@@ -0,0 +1,14 @@
+package de.oliver.fancynpcs.api.actions;
+
+import java.util.List;
+
+public interface ActionManager {
+
+ void registerAction(NpcAction action);
+
+ NpcAction getActionByName(String name);
+
+ void unregisterAction(NpcAction action);
+
+ List getAllActions();
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/ActionTrigger.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/ActionTrigger.java
new file mode 100644
index 00000000..a699f63c
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/ActionTrigger.java
@@ -0,0 +1,36 @@
+package de.oliver.fancynpcs.api.actions;
+
+public enum ActionTrigger {
+ /**
+ * represents any click interaction by a player.
+ */
+ ANY_CLICK,
+ /**
+ * represents a left click interaction by a player.
+ */
+ LEFT_CLICK,
+ /**
+ * represents a right click interaction by a player.
+ */
+ RIGHT_CLICK,
+ /**
+ * represents interactions invoked by the API.
+ */
+ CUSTOM,
+ ;
+
+ /**
+ * Gets the ActionTrigger by its name.
+ *
+ * @param name the name of the ActionTrigger
+ * @return the ActionTrigger or null if not found
+ */
+ public static ActionTrigger getByName(final String name) {
+ for (ActionTrigger trigger : values()) {
+ if (trigger.name().equalsIgnoreCase(name)) {
+ return trigger;
+ }
+ }
+ return null;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/NpcAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/NpcAction.java
new file mode 100644
index 00000000..e0884d0a
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/NpcAction.java
@@ -0,0 +1,48 @@
+package de.oliver.fancynpcs.api.actions;
+
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * The NpcAction class is an abstract class that represents an action that can be performed by an NPC.
+ * Each NpcAction has a name and a flag indicating whether it requires a value.
+ *
+ * The NpcAction class provides an abstract execute method that must be implemented by subclasses
+ * to specify the behavior of the action when executed.
+ *
+ * Subclasses of NpcAction can provide additional data using the NpcActionData record, which includes
+ * an order value to specify the order of execution, the NpcAction itself, and a value associated with
+ * the action.
+ *
+ * This class provides getters for the name and the requiresValue flag of the action.
+ */
+public abstract class NpcAction {
+
+ private final String name;
+ private final boolean requiresValue;
+
+ public NpcAction(String name, boolean requiresValue) {
+ this.name = name;
+ this.requiresValue = requiresValue;
+ }
+
+ /**
+ * Executes the action associated with this NpcAction.
+ *
+ * @param context The context in which the action is being executed.
+ * @param value The value associated with the action. Can be null if no value is required.
+ */
+ public abstract void execute(@NotNull ActionExecutionContext context, @Nullable String value);
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean requiresValue() {
+ return requiresValue;
+ }
+
+ public record NpcActionData(int order, NpcAction action, String value) {
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/executor/ActionExecutionContext.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/executor/ActionExecutionContext.java
new file mode 100644
index 00000000..fe380bcc
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/executor/ActionExecutionContext.java
@@ -0,0 +1,184 @@
+package de.oliver.fancynpcs.api.actions.executor;
+
+import de.oliver.fancynpcs.api.Npc;
+import de.oliver.fancynpcs.api.actions.ActionTrigger;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.types.BlockUntilDoneAction;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Context for executing a sequence of NPC actions initiated by different triggers.
+ */
+public class ActionExecutionContext {
+
+ /**
+ * The trigger that initiated the action.
+ * This is a final variable that represents the specific condition or
+ * event that caused the action to be created in the context.
+ */
+ private final ActionTrigger trigger;
+
+ /**
+ * The NPC that the action is being executed on.
+ */
+ private final Npc npc;
+
+ /**
+ * The player involved in the action, may be null if no player is involved.
+ */
+ private final @Nullable UUID player;
+
+ /**
+ * A list of NpcActionData instances representing the sequence of actions
+ * to be executed for the NPC in the given context.
+ */
+ private final List actions;
+
+ /**
+ * The index of the currently executing action in the list of actions.
+ *
+ * This variable keeps track of which action within the action sequence
+ * is currently being executed. It is incremented as actions are executed
+ * sequentially using the {@link #runNext()} method.
+ *
+ *
+ * The default initial value is 0, indicating the start of the sequence.
+ * When the index is set to -1, it signifies that the sequence has been
+ * terminated and no further actions should be executed.
+ *
+ */
+ private int actionIndex;
+
+ /**
+ * Constructs an ActionExecutionContext with the specified ActionTrigger, Npc, and an optional Player.
+ *
+ * @param trigger the trigger that initiated the action
+ * @param npc the NPC that the action is being executed on
+ * @param player the player involved in the action, may be null if no player is involved
+ */
+ public ActionExecutionContext(ActionTrigger trigger, Npc npc, @Nullable UUID player) {
+ this.trigger = trigger;
+ this.npc = npc;
+ this.player = player;
+
+ this.actions = new ArrayList<>(npc.getData().getActions(trigger));
+ this.actionIndex = 0;
+ }
+
+ /**
+ * Constructs an ActionExecutionContext with the specified ActionTrigger and Npc, without a Player.
+ *
+ * @param trigger the trigger that initiated the action
+ * @param npc the NPC that the action is being executed on
+ */
+ public ActionExecutionContext(ActionTrigger trigger, Npc npc) {
+ this(trigger, npc, null);
+ }
+
+ /**
+ * Executes the action at the specified index within the list of actions.
+ *
+ * @param index the index of the action to be executed. If the index is out of bounds, the method returns immediately.
+ */
+ public void run(int index) {
+ if (index < 0 || index >= actions.size()) {
+ return;
+ }
+
+ NpcAction.NpcActionData actionData = actions.get(index);
+ actionData.action().execute(this, actionData.value());
+ }
+
+ /**
+ * Executes the next action in the list of actions.
+ *
+ * If the current action index is out of bounds, the method returns immediately.
+ * The action index is incremented after the action is executed.
+ *
+ */
+ public void runNext() {
+ if (actionIndex < 0 || actionIndex >= actions.size()) {
+ return;
+ }
+
+ run(actionIndex++);
+ }
+
+ /**
+ * Checks if there are more actions to be executed.
+ *
+ * @return true if there are more actions to be executed, false otherwise
+ */
+ public boolean hasNext() {
+ return actionIndex >= 0 && actionIndex < actions.size();
+ }
+
+ /**
+ * Resets the current action index to its initial state.
+ * This is useful for re-running the sequence of actions from the beginning.
+ */
+ public void reset() {
+ actionIndex = 0;
+ }
+
+ /**
+ * Terminates the current action sequence by setting the action index to -1.
+ * This effectively marks the context as finished and prevents any further actions from being executed.
+ */
+ public void terminate() {
+ actionIndex = -1;
+ }
+
+ /**
+ * Checks if the action sequence has been terminated.
+ *
+ * @return true if the action index is -1, indicating the sequence is terminated; false otherwise
+ */
+ public boolean isTerminated() {
+ return actionIndex == -1;
+ }
+
+ public boolean shouldBlockUntilDone() {
+ for (NpcAction.NpcActionData action : actions) {
+ if (action.action() instanceof BlockUntilDoneAction) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public ActionTrigger getTrigger() {
+ return trigger;
+ }
+
+ public Npc getNpc() {
+ return npc;
+ }
+
+ public List getActions() {
+ return actions;
+ }
+
+ public UUID getPlayerUUID() {
+ return player;
+ }
+
+ public @Nullable Player getPlayer() {
+ if (player == null) {
+ return null;
+ }
+
+ return Bukkit.getPlayer(player);
+ }
+
+ public int getActionIndex() {
+ return actionIndex;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/executor/ActionExecutor.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/executor/ActionExecutor.java
new file mode 100644
index 00000000..f5b97c83
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/executor/ActionExecutor.java
@@ -0,0 +1,42 @@
+package de.oliver.fancynpcs.api.actions.executor;
+
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.Npc;
+import de.oliver.fancynpcs.api.actions.ActionTrigger;
+import org.bukkit.entity.Player;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class ActionExecutor {
+
+ private static final Map runningContexts = new ConcurrentHashMap<>();
+
+ public static void execute(ActionTrigger trigger, Npc npc, Player player) {
+ String key = getKey(trigger, npc, player);
+ ActionExecutionContext runningContext = runningContexts.get(key);
+ if (runningContext != null) {
+ if (runningContext.shouldBlockUntilDone() && !runningContext.isTerminated()) {
+ return;
+ }
+ }
+
+ ActionExecutionContext context = new ActionExecutionContext(trigger, npc, player.getUniqueId());
+ runningContexts.put(key, context);
+
+ FancyNpcsPlugin.get().newThread("FancyNpcs-ActionExecutor", () -> {
+ while (context.hasNext()) {
+ context.runNext();
+ }
+ context.terminate();
+
+ runningContexts.remove(key);
+ }).start();
+
+ }
+
+ private static String getKey(ActionTrigger trigger, Npc npc, Player player) {
+ return trigger.name() + "_" + npc.getData().getId() + "_" + player.getUniqueId();
+ }
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/BlockUntilDoneAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/BlockUntilDoneAction.java
new file mode 100644
index 00000000..f539ad0a
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/BlockUntilDoneAction.java
@@ -0,0 +1,23 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+
+/**
+ * The BlockUntilDoneAction class is a specific implementation of the
+ * NpcAction class that represents an action requiring the NPC (Non-Player
+ * Character) to block its subsequent actions until the current interaction is
+ * completed.
+ *
+ */
+public class BlockUntilDoneAction extends NpcAction {
+
+ public BlockUntilDoneAction() {
+ super("block_until_done", false);
+ }
+
+ @Override
+ public void execute(ActionExecutionContext context, String value) {
+
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/ConsoleCommandAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/ConsoleCommandAction.java
new file mode 100644
index 00000000..47768f7b
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/ConsoleCommandAction.java
@@ -0,0 +1,46 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.bukkit.Bukkit;
+import org.jetbrains.annotations.NotNull;
+import org.lushplugins.chatcolorhandler.ChatColorHandler;
+import org.lushplugins.chatcolorhandler.parsers.ParserTypes;
+
+/**
+ * Represents a console command action that can be executed for an NPC.
+ */
+public class ConsoleCommandAction extends NpcAction {
+
+ public ConsoleCommandAction() {
+ super("console_command", true);
+ }
+
+ /**
+ * Executes the console command action for an NPC.
+ *
+ * @param value The command string to be executed. The value can contain the placeholder "{player}" which will be replaced with the player's name.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ String command = value;
+ if (context.getPlayer() != null) {
+ command = value.replace("{player}", context.getPlayer().getName());
+ }
+
+ String finalCommand = ChatColorHandler.translate(command, context.getPlayer(), ParserTypes.placeholder());
+
+ FancyNpcsPlugin.get().getScheduler().runTask(null, () -> {
+ try {
+ Bukkit.dispatchCommand(Bukkit.getConsoleSender(), finalCommand);
+ } catch (Exception e) {
+ FancyNpcsPlugin.get().getFancyLogger().warn("Failed to execute command: " + finalCommand);
+ }
+ });
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/ExecuteRandomActionAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/ExecuteRandomActionAction.java
new file mode 100644
index 00000000..48d3696d
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/ExecuteRandomActionAction.java
@@ -0,0 +1,41 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Random;
+
+/**
+ * The ExecuteRandomActionAction class represents an action that can be executed randomly by an NPC.
+ *
+ * The ExecuteRandomActionAction class provides an implementation for the execute method,
+ * which executes a random action triggered by the given action trigger on the specified NPC and player.
+ * The execution of the action is based on the actions associated with the NPC's data for the given trigger.
+ */
+public class ExecuteRandomActionAction extends NpcAction {
+
+ public ExecuteRandomActionAction() {
+ super("execute_random_action", false);
+ }
+
+ /**
+ * Executes a random action triggered by the given action trigger on the specified NPC and player.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ int currentIndex = context.getActionIndex();
+ int actionCount = context.getActions().size();
+
+ int randomIndex = getRandomIndex(currentIndex, actionCount);
+
+ NpcActionData action = context.getActions().get(randomIndex);
+ action.action().execute(context, action.value());
+
+ context.terminate();
+ }
+
+ private int getRandomIndex(int from, int to) {
+ return new Random().nextInt(to - from) + from;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/MessageAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/MessageAction.java
new file mode 100644
index 00000000..41200a57
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/MessageAction.java
@@ -0,0 +1,34 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.lushplugins.chatcolorhandler.ModernChatColorHandler;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * The MessageAction class represents an action that sends a message to the player when executed by an NPC.
+ */
+public class MessageAction extends NpcAction {
+
+ public MessageAction() {
+ super("message", true);
+ }
+
+ /**
+ * Executes the action associated with this NpcAction.
+ *
+ * @param value The value passed to the action.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ if (context.getPlayer() == null) {
+ return;
+ }
+
+ context.getPlayer().sendMessage(ModernChatColorHandler.translate(value, context.getPlayer()));
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/NeedPermissionAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/NeedPermissionAction.java
new file mode 100644
index 00000000..1f57ffcb
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/NeedPermissionAction.java
@@ -0,0 +1,28 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+
+public class NeedPermissionAction extends NpcAction {
+
+ public NeedPermissionAction() {
+ super("need_permission", true);
+ }
+
+ @Override
+ public void execute(ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ if (context.getPlayer() == null) {
+ return;
+ }
+
+ if (!context.getPlayer().hasPermission(value)) {
+ FancyNpcsPlugin.get().getTranslator().translate("action_missing_permissions").send(context.getPlayer());
+ context.terminate();
+ }
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlaySoundAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlaySoundAction.java
new file mode 100644
index 00000000..d9cba82a
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlaySoundAction.java
@@ -0,0 +1,38 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+import org.lushplugins.chatcolorhandler.ChatColorHandler;
+import org.lushplugins.chatcolorhandler.parsers.ParserTypes;
+
+public class PlaySoundAction extends NpcAction {
+
+ public PlaySoundAction() {
+ super("play_sound", true);
+ }
+
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ if (context.getPlayer() == null) {
+ return;
+ }
+
+ String sound = ChatColorHandler.translate(value, context.getPlayer(), ParserTypes.placeholder());
+
+ FancyNpcsPlugin.get().getScheduler().runTask(
+ context.getPlayer().getLocation(),
+ () -> {
+ try {
+ context.getPlayer().playSound(context.getPlayer().getLocation(), value, 1.0F, 1.0F);
+ } catch (Exception e) {
+ FancyNpcsPlugin.get().getFancyLogger().warn("Failed to play sound: " + sound);
+ }
+ });
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlayerCommandAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlayerCommandAction.java
new file mode 100644
index 00000000..a4bf6e87
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlayerCommandAction.java
@@ -0,0 +1,60 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import com.google.common.io.ByteArrayDataOutput;
+import com.google.common.io.ByteStreams;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+import org.lushplugins.chatcolorhandler.ChatColorHandler;
+import org.lushplugins.chatcolorhandler.parsers.ParserTypes;
+
+/**
+ * Represents a player command action that can be executed when triggered by an NPC interaction.
+ */
+public class PlayerCommandAction extends NpcAction {
+
+ public PlayerCommandAction() {
+ super("player_command", true);
+ }
+
+ /**
+ * Executes a player command action when triggered by an NPC interaction.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ if (context.getPlayer() == null) {
+ return;
+ }
+
+ String command = ChatColorHandler.translate(value, context.getPlayer(), ParserTypes.placeholder());
+
+ if (command.toLowerCase().startsWith("server")) {
+ String[] args = value.split(" ");
+ if (args.length < 2) {
+ return;
+ }
+ String server = args[1];
+
+ ByteArrayDataOutput out = ByteStreams.newDataOutput();
+ out.writeUTF("Connect");
+ out.writeUTF(server);
+ context.getPlayer().sendPluginMessage(FancyNpcsPlugin.get().getPlugin(), "BungeeCord", out.toByteArray());
+ return;
+ }
+
+ FancyNpcsPlugin.get().getScheduler().runTask(
+ context.getPlayer().getLocation(),
+ () -> {
+ try {
+ context.getPlayer().chat("/" + command);
+ } catch (Exception e) {
+ FancyNpcsPlugin.get().getFancyLogger().warn("Failed to execute command: " + command);
+ }
+ });
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlayerCommandAsOpAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlayerCommandAsOpAction.java
new file mode 100644
index 00000000..e5d09ac2
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/PlayerCommandAsOpAction.java
@@ -0,0 +1,67 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import com.google.common.io.ByteArrayDataOutput;
+import com.google.common.io.ByteStreams;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+import org.lushplugins.chatcolorhandler.ChatColorHandler;
+import org.lushplugins.chatcolorhandler.parsers.ParserTypes;
+
+/**
+ * PlayerCommandAsOpAction is a npc action that allows a player to execute a command as an operator when triggered by an NPC interaction.
+ */
+public class PlayerCommandAsOpAction extends NpcAction {
+
+ public PlayerCommandAsOpAction() {
+ super("player_command_as_op", true);
+ }
+
+ /**
+ * Executes a player command as an operator when triggered by an NPC interaction.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ if (context.getPlayer() == null) {
+ return;
+ }
+
+ String command = ChatColorHandler.translate(value, context.getPlayer(), ParserTypes.placeholder());
+
+ if (command.toLowerCase().startsWith("server")) {
+ String[] args = value.split(" ");
+ if (args.length < 2) {
+ return;
+ }
+ String server = args[1];
+
+ ByteArrayDataOutput out = ByteStreams.newDataOutput();
+ out.writeUTF("Connect");
+ out.writeUTF(server);
+ context.getPlayer().sendPluginMessage(FancyNpcsPlugin.get().getPlugin(), "BungeeCord", out.toByteArray());
+ return;
+ }
+
+ FancyNpcsPlugin.get().getScheduler().runTask(
+ context.getPlayer().getLocation(),
+ () -> {
+ boolean wasOp = context.getPlayer().isOp();
+
+ context.getPlayer().setOp(true);
+ try {
+ context.getPlayer().chat("/" + command);
+ } catch (Exception e) {
+ FancyNpcsPlugin.get().getFancyLogger().warn("Failed to execute command: " + command);
+ } finally {
+ context.getPlayer().setOp(wasOp);
+ }
+ }
+ );
+ }
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/SendToServerAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/SendToServerAction.java
new file mode 100644
index 00000000..bad77c23
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/SendToServerAction.java
@@ -0,0 +1,40 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import com.google.common.io.ByteArrayDataOutput;
+import com.google.common.io.ByteStreams;
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * The SendToServerAction class is a subclass of NpcAction that represents an action
+ * to send data to the server using BungeeCord messaging.
+ */
+public class SendToServerAction extends NpcAction {
+
+ public SendToServerAction() {
+ super("send_to_server", true);
+ }
+
+ /**
+ * Executes the action associated with this NpcAction.
+ *
+ * @param value The value associated with the action.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ if (context.getPlayer() == null) {
+ return;
+ }
+
+ ByteArrayDataOutput out = ByteStreams.newDataOutput();
+ out.writeUTF("Connect");
+ out.writeUTF(value);
+ context.getPlayer().sendPluginMessage(FancyNpcsPlugin.get().getPlugin(), "BungeeCord", out.toByteArray());
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/WaitAction.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/WaitAction.java
new file mode 100644
index 00000000..2caab984
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/actions/types/WaitAction.java
@@ -0,0 +1,39 @@
+package de.oliver.fancynpcs.api.actions.types;
+
+import de.oliver.fancynpcs.api.FancyNpcsPlugin;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import de.oliver.fancynpcs.api.actions.executor.ActionExecutionContext;
+import org.jetbrains.annotations.NotNull;
+
+public class WaitAction extends NpcAction {
+
+ public WaitAction() {
+ super("wait", true);
+ }
+
+ /**
+ * Executes the "wait" action for an NPC.
+ *
+ * @param value The value representing the time to wait in seconds.
+ */
+ @Override
+ public void execute(@NotNull ActionExecutionContext context, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ int time;
+ try {
+ time = Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ FancyNpcsPlugin.get().getFancyLogger().warn("Invalid time value for wait action: " + value);
+ return;
+ }
+
+ try {
+ Thread.sleep(time * 1000L);
+ } catch (InterruptedException e) {
+ FancyNpcsPlugin.get().getFancyLogger().warn("Thread was interrupted while waiting");
+ }
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcCreateEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcCreateEvent.java
new file mode 100644
index 00000000..f9afcffb
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcCreateEvent.java
@@ -0,0 +1,60 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Is fired when a new NPC is being created
+ */
+public class NpcCreateEvent extends Event implements Cancellable {
+
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @NotNull
+ private final CommandSender creator;
+ private boolean isCancelled;
+
+ public NpcCreateEvent(@NotNull Npc npc, @NotNull CommandSender creator) {
+ this.npc = npc;
+ this.creator = creator;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the created npc
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the player who created the npc
+ */
+ public @NotNull CommandSender getCreator() {
+ return creator;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return isCancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.isCancelled = cancel;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcInteractEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcInteractEvent.java
new file mode 100644
index 00000000..0382f8db
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcInteractEvent.java
@@ -0,0 +1,95 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import de.oliver.fancynpcs.api.actions.ActionTrigger;
+import de.oliver.fancynpcs.api.actions.NpcAction;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Is fired when a player interacts with a NPC
+ */
+public class NpcInteractEvent extends Event implements Cancellable {
+
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @Nullable
+ private final List actions;
+ @NotNull
+ private final Consumer onClick;
+ @NotNull
+ private final Player player;
+ private final ActionTrigger actionTrigger;
+ private boolean isCancelled;
+
+ public NpcInteractEvent(@NotNull Npc npc, @NotNull Consumer onClick, @NotNull List actions, @NotNull Player player, @NotNull ActionTrigger actionTrigger) {
+ this.npc = npc;
+ this.onClick = onClick;
+ this.actions = actions;
+ this.player = player;
+ this.actionTrigger = actionTrigger;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the modified npc
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the custom on click method that will run
+ */
+ public @NotNull Consumer getOnClick() {
+ return onClick;
+ }
+
+ /**
+ * @return the actions that will run
+ */
+ public @Nullable List getActions() {
+ return actions;
+ }
+
+ /**
+ * @return returns interaction type
+ */
+ public @NotNull ActionTrigger getInteractionType() {
+ return actionTrigger;
+ }
+
+ /**
+ * @return the player who interacted with the npc
+ */
+ public @NotNull Player getPlayer() {
+ return player;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return isCancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.isCancelled = cancel;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcModifyEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcModifyEvent.java
new file mode 100644
index 00000000..c5a29f5e
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcModifyEvent.java
@@ -0,0 +1,117 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import org.bukkit.command.CommandSender;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Is fired when a NPC is being modified
+ */
+public class NpcModifyEvent extends Event implements Cancellable {
+
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @NotNull
+ private final NpcModification modification;
+ @NotNull
+ private final Object newValue;
+ @NotNull
+ private final CommandSender modifier;
+ private boolean isCancelled;
+
+ public NpcModifyEvent(@NotNull Npc npc, @NotNull NpcModification modification, Object newValue, @NotNull CommandSender modifier) {
+ this.npc = npc;
+ this.modification = modification;
+ this.newValue = newValue;
+ this.modifier = modifier;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the modified npc
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the modification that was being made
+ */
+ public @NotNull NpcModification getModification() {
+ return modification;
+ }
+
+ /**
+ * @return the value that is being set
+ */
+ public @NotNull Object getNewValue() {
+ return newValue;
+ }
+
+ /**
+ * @return the sender who modified the npc
+ */
+ public @NotNull CommandSender getModifier() {
+ return modifier;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return isCancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.isCancelled = cancel;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+ public enum NpcModification {
+ ATTRIBUTE,
+ COLLIDABLE,
+ DISPLAY_NAME,
+ EQUIPMENT,
+ GLOWING,
+ GLOWING_COLOR,
+ INTERACTION_COOLDOWN,
+ SCALE,
+ VISIBILITY_DISTANCE,
+ LOCATION,
+ MIRROR_SKIN,
+ PLAYER_COMMAND,
+ SERVER_COMMAND,
+ SHOW_IN_TAB,
+ SKIN,
+ TURN_TO_PLAYER,
+ TYPE,
+ // Messages.
+ MESSAGE_ADD,
+ MESSAGE_SET,
+ MESSAGE_REMOVE,
+ MESSAGE_CLEAR,
+ MESSAGE_SEND_RANDOMLY,
+ // Player commands.
+ PLAYER_COMMAND_ADD,
+ PLAYER_COMMAND_SET,
+ PLAYER_COMMAND_REMOVE,
+ PLAYER_COMMAND_CLEAR,
+ PLAYER_COMMAND_SEND_RANDOMLY,
+ // Server commands.
+ SERVER_COMMAND_ADD,
+ SERVER_COMMAND_SET,
+ SERVER_COMMAND_REMOVE,
+ SERVER_COMMAND_CLEAR,
+ SERVER_COMMAND_SEND_RANDOMLY
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcRemoveEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcRemoveEvent.java
new file mode 100644
index 00000000..c61de4cc
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcRemoveEvent.java
@@ -0,0 +1,59 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import org.bukkit.command.CommandSender;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Is fired when a NPC is being deleted
+ */
+public class NpcRemoveEvent extends Event implements Cancellable {
+
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @NotNull
+ private final CommandSender receiver;
+ private boolean isCancelled;
+
+ public NpcRemoveEvent(@NotNull Npc npc, @NotNull CommandSender receiver) {
+ this.npc = npc;
+ this.receiver = receiver;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the npc that is being removed
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the player who removed the npc
+ */
+ public @NotNull CommandSender getSender() {
+ return receiver;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return isCancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.isCancelled = cancel;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcSpawnEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcSpawnEvent.java
new file mode 100644
index 00000000..3274a356
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcSpawnEvent.java
@@ -0,0 +1,59 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Is fired when a NPC is being spawned
+ */
+public class NpcSpawnEvent extends Event implements Cancellable {
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @NotNull
+ private final Player player;
+ private boolean isCancelled;
+
+ public NpcSpawnEvent(@NotNull Npc npc, @NotNull Player player) {
+ super(true);
+ this.npc = npc;
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the npc that is being spawned
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the player to whom the spawn packets are being sent
+ */
+ public @NotNull Player getPlayer() {
+ return player;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return isCancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.isCancelled = cancel;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+}
\ No newline at end of file
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcStartLookingEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcStartLookingEvent.java
new file mode 100644
index 00000000..ea1e0eb7
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcStartLookingEvent.java
@@ -0,0 +1,48 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Is fired when NPC starts looking at a player.
+ */
+public class NpcStartLookingEvent extends Event {
+
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @NotNull
+ private final Player player;
+
+ public NpcStartLookingEvent(@NotNull Npc npc, @NotNull Player player) {
+ this.npc = npc;
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the npc that started looking at a player
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the player who npc started looking at
+ */
+ public @NotNull Player getPlayer() {
+ return player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcStopLookingEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcStopLookingEvent.java
new file mode 100644
index 00000000..00db0407
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcStopLookingEvent.java
@@ -0,0 +1,48 @@
+package de.oliver.fancynpcs.api.events;
+
+import de.oliver.fancynpcs.api.Npc;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Is fired when NPC stops looking at a player.
+ */
+public class NpcStopLookingEvent extends Event {
+
+ private static final HandlerList handlerList = new HandlerList();
+ @NotNull
+ private final Npc npc;
+ @NotNull
+ private final Player player;
+
+ public NpcStopLookingEvent(@NotNull Npc npc, @NotNull Player player) {
+ this.npc = npc;
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ /**
+ * @return the npc that stopped looking at a player
+ */
+ public @NotNull Npc getNpc() {
+ return npc;
+ }
+
+ /**
+ * @return the player who npc stopped looking at
+ */
+ public @NotNull Player getPlayer() {
+ return player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcsLoadedEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcsLoadedEvent.java
new file mode 100644
index 00000000..a264b1ca
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/NpcsLoadedEvent.java
@@ -0,0 +1,24 @@
+package de.oliver.fancynpcs.api.events;
+
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.ApiStatus;
+
+/**
+ * Is fired when all NPCs are loaded.
+ *
+ * Will be removed, once the npc loading is coupled with the loading of worlds! Be aware of that!
+ */
+@ApiStatus.Experimental()
+public class NpcsLoadedEvent extends Event {
+ private static final HandlerList handlerList = new HandlerList();
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ @Override
+ public HandlerList getHandlers() {
+ return handlerList;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/PacketReceivedEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/PacketReceivedEvent.java
new file mode 100644
index 00000000..5b4df3b4
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/events/PacketReceivedEvent.java
@@ -0,0 +1,36 @@
+package de.oliver.fancynpcs.api.events;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+public class PacketReceivedEvent extends Event {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+ private final Object packet;
+ private final Player player;
+
+ public PacketReceivedEvent(Object packet, Player player) {
+ this.packet = packet;
+ this.player = player;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public Object getPacket() {
+ return packet;
+ }
+
+ public Player getPlayer() {
+ return player;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinData.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinData.java
new file mode 100644
index 00000000..d7d1b62e
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinData.java
@@ -0,0 +1,65 @@
+package de.oliver.fancynpcs.api.skins;
+
+public class SkinData {
+
+ private String identifier;
+ private SkinVariant variant;
+
+ private String textureValue;
+ private String textureSignature;
+
+ public SkinData(String identifier, SkinVariant variant, String textureValue, String textureSignature) {
+ this.identifier = identifier;
+ this.variant = variant;
+ this.textureValue = textureValue;
+ this.textureSignature = textureSignature;
+ }
+
+ public SkinData(String identifier, SkinVariant variant) {
+ this(identifier, variant, null, null);
+ }
+
+ public boolean hasTexture() {
+ return textureValue != null &&
+ textureSignature != null &&
+ !textureValue.isEmpty() &&
+ !textureSignature.isEmpty();
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ public void setIdentifier(String identifier) {
+ this.identifier = identifier;
+ }
+
+ public SkinVariant getVariant() {
+ return variant;
+ }
+
+ public void setVariant(SkinVariant variant) {
+ this.variant = variant;
+ }
+
+ public String getTextureValue() {
+ return textureValue;
+ }
+
+ public void setTextureValue(String textureValue) {
+ this.textureValue = textureValue;
+ }
+
+ public String getTextureSignature() {
+ return textureSignature;
+ }
+
+ public void setTextureSignature(String textureSignature) {
+ this.textureSignature = textureSignature;
+ }
+
+ public enum SkinVariant {
+ AUTO,
+ SLIM,
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinGeneratedEvent.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinGeneratedEvent.java
new file mode 100644
index 00000000..65ccd10b
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinGeneratedEvent.java
@@ -0,0 +1,49 @@
+package de.oliver.fancynpcs.api.skins;
+
+import org.bukkit.Bukkit;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Event that is called when a skin is generated
+ */
+public class SkinGeneratedEvent extends Event {
+
+ private static final HandlerList handlerList = new HandlerList();
+
+ @NotNull
+ private final String id;
+
+ @Nullable
+ private final SkinData skin;
+
+ public SkinGeneratedEvent(@NotNull String id, @Nullable SkinData skin) {
+ super(!Bukkit.isPrimaryThread());
+ this.id = id;
+ this.skin = skin;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlerList;
+ }
+
+ public @NotNull String getId() {
+ return id;
+ }
+
+ /**
+ * Get the skin that was generated
+ *
+ * @return the skin that was generated or null if the skin could not be generated
+ */
+ public @Nullable SkinData getSkin() {
+ return skin;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlerList;
+ }
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinManager.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinManager.java
new file mode 100644
index 00000000..1ac2c537
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/skins/SkinManager.java
@@ -0,0 +1,43 @@
+package de.oliver.fancynpcs.api.skins;
+
+import java.util.UUID;
+
+public interface SkinManager {
+
+ /**
+ * Fetch a skin by its identifier and variant
+ *
+ * @param identifier either a valid UUID, username, URL or file path
+ * @return the skin data, if the skin was cached. Otherwise, null is returned and the skin is fetched asynchronously. You can listen to the {@link SkinGeneratedEvent} to get the skin data
+ */
+ SkinData getByIdentifier(String identifier, SkinData.SkinVariant variant);
+
+ /**
+ * Fetch a skin by a UUID of a player
+ *
+ * @return the skin data, if the skin was cached. Otherwise, null is returned and the skin is fetched asynchronously. You can listen to the {@link SkinGeneratedEvent} to get the skin data
+ */
+ SkinData getByUUID(UUID uuid, SkinData.SkinVariant variant);
+
+ /**
+ * Fetch a skin by a username of a player
+ *
+ * @return the skin data, if the skin was cached. Otherwise, null is returned and the skin is fetched asynchronously. You can listen to the {@link SkinGeneratedEvent} to get the skin data
+ */
+ SkinData getByUsername(String username, SkinData.SkinVariant variant);
+
+ /**
+ * Fetch a skin by a URL pointing to a skin image
+ *
+ * @return the skin data, if the skin was cached. Otherwise, null is returned and the skin is fetched asynchronously. You can listen to the {@link SkinGeneratedEvent} to get the skin data
+ */
+ SkinData getByURL(String url, SkinData.SkinVariant variant);
+
+ /**
+ * Fetch a skin by a file path pointing to a skin image (relative to plugins/FancyNPCs/skins)
+ *
+ * @return the skin data, if the skin was cached. Otherwise, null is returned and the skin is fetched asynchronously. You can listen to the {@link SkinGeneratedEvent} to get the skin data
+ */
+ SkinData getByFile(String filePath, SkinData.SkinVariant variant);
+
+}
diff --git a/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/utils/Interval.java b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/utils/Interval.java
new file mode 100644
index 00000000..aed64905
--- /dev/null
+++ b/plugins/fancynpcs/api/src/main/java/de/oliver/fancynpcs/api/utils/Interval.java
@@ -0,0 +1,217 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2023 Grabsky <44530932+Grabsky@users.noreply.github.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * HORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+package de.oliver.fancynpcs.api.utils;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Instant;
+import java.util.Date;
+
+import static de.oliver.fancynpcs.api.utils.Interval.Unit.*;
+
+
+/**
+ * {@link Interval} is simple (but not very extensible) object that provides methods for
+ * unit conversion and creation of human-readable 'elapsed time' strings.
+ *
+ * This API is for internal use only and can change at any time.
+ */
+@ApiStatus.Internal
+public final class Interval {
+
+ private final long value;
+
+ public Interval(final long value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns {@link Interval} object of current time.
+ */
+ public static @NotNull Interval now() {
+ return new Interval(System.currentTimeMillis());
+ }
+
+ /**
+ * Returns {@link Interval} object constructed from provided {@link Long long} {@code (interval)}.
+ * It is expected that provided value is already a difference between two timestamps.
+ */
+ public static @NotNull Interval of(final long interval, final @NotNull Unit unit) {
+ return new Interval(interval * unit.factor);
+ }
+
+ /**
+ * Returns {@link Interval} object constructed from provided {@link Double double} {@code (interval)}.
+ * It is expected that provided value is already a difference between two timestamps.
+ */
+ public static @NotNull Interval of(final double interval, final @NotNull Unit unit) {
+ return new Interval(Math.round(interval * unit.factor));
+ }
+
+ /**
+ * Returns {@link Interval} of time between {@code n} and {@code m}.
+ */
+ public static @NotNull Interval between(final long n, final long m, final @NotNull Unit unit) {
+ return new Interval((n - m) * unit.factor);
+ }
+
+ /**
+ * Returns {@link Interval} of time between {@code n} and {@code m}.
+ */
+ public static @NotNull Interval between(final double n, final double m, final @NotNull Unit unit) {
+ return new Interval(Math.round((n - m) * unit.factor));
+ }
+
+ /**
+ * Returns interval converted to specified {@link Unit} {@code (unit)}.
+ *