Compare commits

...
73 Commits
Author SHA1 Message Date
Eisi05 ac34ba181d Fixed NPE 2026-06-15 14:40:16 +02:00
Eisi05 66d58dd71c 2.3.3
- Performance Improvement
- Bugfixes
2026-06-15 14:07:34 +02:00
Eisi05 c861a13d5e Performance improvements 2026-06-13 19:55:00 +02:00
Eisi05 6b3b7c5e6f Fixed bugs 2026-06-11 14:05:40 +02:00
Eisi05 a5f2cc5378 2.3.2
- Improved pathing (using BoundingBox)
2026-06-05 20:21:22 +02:00
Eisi05 9dd167219a bump 2026-06-03 23:51:09 +02:00
Eisi05 5491ba198b 2.3.1-beta
- Added hitbox to sleeping npcs
2026-06-03 23:17:44 +02:00
Eisi05 991cea235a Removed unnecessary fields 2026-06-03 11:33:51 +02:00
Eisi05 19fefc2fd3 fixed java 21 methods 2026-06-02 22:12:56 +02:00
Eisi05 24a50f7239 bump 2.3.0-beta 2026-06-02 22:01:33 +02:00
Eisi05 7489f7024b Merge remote-tracking branch 'origin/feature/conditions' 2026-06-02 19:51:08 +02:00
Eisi05 4d37ce07af Checked mapping 2026-06-02 19:50:19 +02:00
Eisi05 d5df3e9ea5 Improved pathing by TerZer 2026-06-02 19:48:32 +02:00
Eisi05 66836699f5 Fixed sleeping position 2026-06-02 19:02:56 +02:00
Eisi05 d11cf4d95e Fixed npc disappearing 2026-06-02 10:39:23 +02:00
Eisi05 ec0db9e578 Fixed npc disappearing 2026-06-02 10:32:41 +02:00
Eisi05 e653f102e3 - Added WalkPathGoal
- Improved Reflections
2026-06-02 09:37:56 +02:00
Eisi05 e2dded2988 2.3.0
- Added custom condition to Goal
2026-05-30 22:28:27 +02:00
Eisi05 692da7013d bump 2.2.2 2026-05-29 16:45:36 +02:00
Eisi05 25dadda7fe 2.2.2
- Fixed options copying
2026-05-29 16:41:56 +02:00
Eisi05 c0cb1dd35e bump 2.2.1 2026-05-28 14:54:41 +02:00
Eisi05 91c4d4fe89 Fixed merge 2026-05-28 14:40:24 +02:00
Eisi05 f28f6a2231 Merge branch 'feature/ai'
# Conflicts:
#	pom.xml
#	src/main/java/de/eisi05/npc/api/objects/NpcOption.java
#	src/main/java/de/eisi05/npc/api/objects/Skin.java
2026-05-28 14:38:35 +02:00
Eisi05 a05f8964ba Test 2026-05-28 14:33:48 +02:00
Eisi05 5c195036fe Test 2026-05-27 00:24:25 +02:00
Eisi05 13d1dfab51 added method to setOption for one player 2026-05-25 23:10:09 +02:00
Eisi05 93071afab8 Fixed player looking when sleeping 2026-05-24 22:20:03 +02:00
Eisi05 122e203e81 Fixed skin from file for 1.17. -> Used same Implementation used for Paper 2026-05-24 19:38:22 +02:00
Eisi05 97b57a6eeb Updated README 2026-05-21 15:41:40 +02:00
Eisi05 12519ec43d Added autoManageVisibility to config 2026-05-07 15:57:25 +02:00
Eisi05 d34b9bfdf0 Added autoManageVisibility to config 2026-05-07 14:10:02 +02:00
Eisi05 c9c8abe613 fixed npc visibility 2026-05-06 22:22:41 +02:00
Eisi05 da80e5565b fixed saving 2026-05-03 16:09:26 +02:00
Eisi05 31589150ca Fixed goal removal 2026-05-03 15:39:15 +02:00
Eisi05 4bd46f43d3 Fixed goal removal 2026-05-03 15:02:38 +02:00
Eisi05 5fe0e4209d Improved goal removing 2026-05-03 14:44:15 +02:00
Eisi05 88b1b93dc8 moved to alpha 2026-05-03 13:57:51 +02:00
Eisi05 05ddff2901 added doc 2026-05-03 12:22:35 +02:00
Eisi05 28954e2fec Merge branch 'master' into feature/ai
# Conflicts:
#	pom.xml
#	src/main/java/de/eisi05/npc/api/objects/NpcHolder.java
2026-05-03 11:06:00 +02:00
Eisi05 722c099a41 bump 2.1.3 2026-05-03 10:56:07 +02:00
Eisi05 6ad1d384ad fixed options 2026-05-03 10:55:32 +02:00
Eisi05 d690d9f697 Updated wiki 2026-05-02 23:20:07 +02:00
Eisi05 8a4572639d Added removeQueue to GoalSelector 2026-05-02 22:07:59 +02:00
Eisi05 7eb0ba635e Improved goals 2026-05-02 16:12:09 +02:00
Eisi05 7213f0474b Added getters to goals 2026-04-26 20:02:17 +02:00
Eisi05 c378235c78 Improved WalkToLocationGoal 2026-04-24 20:17:22 +02:00
Eisi05 5aebec5b19 fixed FollowEntityGoal 2026-04-24 16:28:18 +02:00
Eisi05 f141b14cbf fixed async tasks 2026-04-24 16:22:15 +02:00
Eisi05 082be5390e fixed async tasks 2026-04-24 16:11:38 +02:00
Eisi05 1e616a47b7 Added new priority 2026-04-24 11:14:15 +02:00
Eisi05 2df423c9a6 Moved goal adding to NpcHolder 2026-04-23 20:18:23 +02:00
Eisi05 5d17900144 Improved WalkToLocationGoal 2026-04-23 17:55:24 +02:00
Eisi05 0a1d6a88c4 Added TODO 2026-04-23 10:51:55 +02:00
Eisi05 c05c39387c fixed option in NpcHolder 2026-04-22 22:57:15 +02:00
Eisi05 27f21b816d Fixed per player options 2026-04-22 18:28:45 +02:00
Eisi05 2cab200f4d Improved goal management 2026-04-22 18:24:52 +02:00
Eisi05 fd5f859c04 Improved WalkToLocationGoal 2026-04-22 17:17:41 +02:00
Eisi05 15b8958252 Improved goals 2026-04-22 17:13:37 +02:00
Eisi05 a3b3495f9a Fixed npc loading issue 2026-04-22 16:27:37 +02:00
Eisi05 1e7faa0a39 Improved pathing 2026-04-22 16:04:52 +02:00
Eisi05 b2bf8bb654 Improved movement goals 2026-04-21 23:53:52 +02:00
Eisi05 f06b9fbf52 Updated doc 2026-04-21 17:30:03 +02:00
Eisi05 663225bd13 Improved Goals 2026-04-21 17:27:39 +02:00
Eisi05 478763b3e0 First version of AI Goals 2026-04-20 23:11:30 +02:00
Eisi05 96587a91ff 2.1.2
- fixed PathfindingUtils
2026-04-19 23:34:53 +02:00
Eisi05 64e3db8952 fixed MovementReplayer 2026-04-14 23:00:33 +02:00
Eisi05 2804204a26 Update bug_report.yml 2026-04-13 14:20:09 +02:00
Eisi05 3d08a8e501 Update bug_report.yml 2026-04-13 14:19:53 +02:00
Eisi05 2b15318159 2.1.1
- Fixed MovementRecording
2026-04-11 13:12:50 +02:00
Eisi05 b83298bb0f Improved MovementReplayer 2026-04-10 22:01:20 +02:00
Eisi05 7c176a69bf Update installation instructions in README.md
Removed warning about version 2.0.0-beta instability and updated last stable version.
2026-03-24 21:13:45 +01:00
Eisi05 38342d1795 Enhance error logs section with debugging tips
Updated the description for error logs to include debugging instructions.
2026-03-24 21:12:23 +01:00
Eisi05 0857493c50 bump 2.1.0
Updated NpcApi version from 1.4.6 to 2.1.0 in multiple sections.
2026-03-24 21:05:24 +01:00
51 changed files with 6395 additions and 648 deletions

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ body:
id: logs
attributes:
label: Error Logs
description: Paste any error logs here
description: Paste any error logs here. It might be helpful to run `/npc config debug true` and `/npc config reload` to see an error message in the console.
render: text
- type: textarea
id: media
+1 -11
View File
@@ -7,10 +7,7 @@ on:
jobs:
notify_comment:
runs-on: ubuntu-latest
# ⬇️ ADD THIS CONDITION TO THE *JOB*
# This skips the *entire job* if the commenter is the user you want to ignore.
# Replace 'YOUR-USERNAME-TO-IGNORE' with your actual GitHub login.
if: github.event.comment.user.login != 'Eisi05'
steps:
@@ -22,15 +19,9 @@ jobs:
const comment = context.payload.comment;
const issue = context.payload.issue;
// ⬇️ REMOVE THIS LINE - the job-level 'if' handles this now.
// if (comment.user.login === github.actor) return;
// 1. Comment body (truncate to 1024)
let body = comment.body || 'No content provided.';
if (body.length > 1024) body = body.substring(0, 1021) + '...';
// ... (rest of your script is good) ...
body = "\n" + body + "\n";
function escapeJSON(str) {
return str.replace(/\\/g, '\\\\')
@@ -45,7 +36,6 @@ jobs:
core.setOutput('title', escapeJSON(`Comment on #${issue.number}: ${issue.title}`));
core.setOutput('description', body);
core.setOutput('color', color);
// ... (all your other core.setOutput lines) ...
core.setOutput('labels', escapeJSON(labelsText));
core.setOutput('issue_url', issue.html_url);
core.setOutput('comment_author', comment.user.login);
+4 -8
View File
@@ -28,10 +28,6 @@ See the in-repo wiki pages under:
## Installation
Choose your preferred installation method based on your project needs:
> [!WARNING]
> Version 2.0.0-beta is currently not stable, use at your own risk!
> Last stable version is **1.4.6**.
### Method 1: Plugin Dependency (Recommended)
This method requires [NpcPlugin](https://www.spigotmc.org/resources/npc-plugin-1-17.87761/) to be installed as a separate plugin on the server.
@@ -48,7 +44,7 @@ This method requires [NpcPlugin](https://www.spigotmc.org/resources/npc-plugin-1
<dependency>
<groupId>com.github.Eisi05</groupId>
<artifactId>NpcApi</artifactId>
<version>1.4.6</version>
<version>2.3.3</version>
<scope>provided</scope>
</dependency>
```
@@ -64,7 +60,7 @@ dependencyResolutionManagement {
}
dependencies {
compileOnly 'com.github.Eisi05:NpcApi:1.4.6'
compileOnly 'com.github.Eisi05:NpcApi:2.3.3'
}
```
@@ -97,7 +93,7 @@ Add the repository and dependency to your `pom.xml`:
<dependency>
<groupId>com.github.Eisi05</groupId>
<artifactId>NpcApi</artifactId>
<version>1.4.6</version>
<version>2.3.3</version>
</dependency>
</dependencies>
```
@@ -114,7 +110,7 @@ dependencyResolutionManagement {
}
dependencies {
implementation 'com.github.Eisi05:NpcApi:1.4.6'
implementation 'com.github.Eisi05:NpcApi:2.3.3'
}
```
+1 -13
View File
@@ -6,7 +6,7 @@
<groupId>de.eisi05</groupId>
<artifactId>NpcApi</artifactId>
<version>2.1.0</version>
<version>2.3.3</version>
<packaging>jar</packaging>
<name>NpcApi</name>
@@ -103,17 +103,5 @@
<version>1.10.8</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.16</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
+49 -19
View File
@@ -4,6 +4,7 @@ import de.eisi05.npc.api.listeners.*;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcConfig;
import de.eisi05.npc.api.objects.NpcHolder;
import de.eisi05.npc.api.pathfinding.Path;
import de.eisi05.npc.api.scheduler.Tasks;
import de.eisi05.npc.api.utils.Metrics;
@@ -19,35 +20,36 @@ import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* The main entry point and singleton class for the NPC API.
* This class handles the initialization, configuration, and shutdown
* of the NPC functionality within a Bukkit plugin.
* The main entry point and singleton class for the NPC API. This class handles the initialization, configuration, and shutdown of the NPC functionality within
* a Bukkit plugin.
*/
public final class NpcApi
{
private static final List<Listener> listeners = List.of(new ChangeWorldListener(), new ConnectionListener(), new NpcInteractListener(),
new WorldLoadListener(), new ServerReadyListener());
private static final List<Listener> listeners = new ArrayList<>(List.of(new ChangeWorldListener(), new ConnectionListener(), new NpcInteractListener(),
new WorldLoadListener(), new ServerReadyListener(), new ProjectileHitListener()));
/**
* A static reference to the Bukkit plugin instance that is using this API.
* This is set during the API's initialization.
* A static reference to the Bukkit plugin instance that is using this API. This is set during the API's initialization.
*/
public static Plugin plugin;
public static Function<Player, String> DISABLED_MESSAGE_PROVIDER = player -> ChatColor.RED + "DISABLED";
/**
* The configuration object for the NPC API, containing various settings
* like the look-at timer.
* The configuration object for the NPC API, containing various settings like the look-at timer.
*/
public static NpcConfig config = new NpcConfig();
private static Listener sleepListener;
private static NpcApi npcApi;
/**
* Private constructor to enforce the singleton pattern.
* Initializes the API by registering listeners, loading existing NPCs,
* injecting packet readers, and starting recurring tasks.
* Private constructor to enforce the singleton pattern. Initializes the API by registering listeners, loading existing NPCs, injecting packet readers, and
* starting recurring tasks.
*
* @param plugin The {@link JavaPlugin} instance using this API. Must not be {@code null}.
* @param config The {@link NpcConfig} object for the API. Must not be {@code null}.
@@ -57,6 +59,9 @@ public final class NpcApi
NpcApi.plugin = plugin;
NpcApi.config = config;
if(config.preciseSleepingHitbox())
listeners.add(sleepListener = new NpcSleepListener());
listeners.forEach(listener -> Bukkit.getPluginManager().registerEvents(listener, plugin));
ConfigurationSerialization.registerClass(Path.class);
@@ -70,8 +75,8 @@ public final class NpcApi
}
/**
* Creates or retrieves the singleton instance of the {@code NpcApi} with a default configuration.
* If the API instance does not exist or the provided plugin is null, a new instance is created.
* Creates or retrieves the singleton instance of the {@code NpcApi} with a default configuration. If the API instance does not exist or the provided plugin
* is null, a new instance is created.
*
* @param plugin The {@link JavaPlugin} instance using this API. Must not be {@code null}.
* @return The singleton {@link NpcApi} instance. Must not be {@code null}.
@@ -82,8 +87,8 @@ public final class NpcApi
}
/**
* Creates or retrieves the singleton instance of the {@code NpcApi} with a custom configuration.
* If the API instance does not exist or the provided plugin is null, a new instance is created.
* Creates or retrieves the singleton instance of the {@code NpcApi} with a custom configuration. If the API instance does not exist or the provided plugin
* is null, a new instance is created.
*
* @param plugin The {@link JavaPlugin} instance using this API. Must not be {@code null}.
* @param config The {@link NpcConfig} object to use for the API. Must not be {@code null}.
@@ -98,12 +103,25 @@ public final class NpcApi
}
/**
* Disables the NPC API, performing the necessary cleanup.
* This includes hiding all active NPCs from players, clearing the NPC manager,
* and uninjecting packet readers. It also nullifies the static references.
* Disables the NPC API, performing the necessary cleanup. This includes hiding all active NPCs from players, clearing the NPC manager, and uninjecting
* packet readers. It also nullifies the static references.
*/
public static void disable()
{
List<NpcHolder> npcsToSave = new ArrayList<>(NpcManager.getList());
npcsToSave.stream().filter(NpcHolder::hasUnsavedChanges).parallel().forEach(npc ->
{
if(npc instanceof NPC npc1 && !npc1.isSaved())
return;
try
{
npc.save();
}
catch(IOException e)
{
}
});
NpcManager.getList().forEach(NPC::hideNpcFromAllPlayers);
NpcManager.clear();
PacketReader.uninjectAll();
@@ -117,6 +135,18 @@ public final class NpcApi
npcApi = null;
}
/**
* Reloads the sleep listener, unregistering the old one and registering a new one.
*/
public static void reloadSleepListener()
{
if(sleepListener != null && !config.preciseSleepingHitbox())
HandlerList.unregisterAll(sleepListener);
if(config.preciseSleepingHitbox())
Bukkit.getPluginManager().registerEvents(sleepListener = new NpcSleepListener(), plugin);
}
/**
* Sets a function that provides the message shown when an NPC is disabled.
*
@@ -0,0 +1,192 @@
package de.eisi05.npc.api.ai;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.utils.SerializablePredicate;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.Serial;
import java.io.Serializable;
/**
* Base abstract class for NPC AI goals. Goals represent specific behaviors that an NPC can perform, such as walking to a location, attacking an entity, or
* looking around. Extend this class to create custom goal behaviors.
*/
public abstract class Goal implements Serializable
{
@Serial
private final static long serialVersionUID = 1L;
private Priority priority;
private SerializablePredicate<NPC> condition;
/**
* Creates a new goal with the specified priority.
*
* @param priority The priority level for this goal
*/
protected Goal(@NotNull Priority priority)
{
this.priority = priority;
}
private Goal()
{
}
/**
* Gets the condition for this goal. The condition is checked before the goal is executed.
*
* @return The condition for this goal
*/
public @Nullable SerializablePredicate<NPC> getCondition()
{
return condition;
}
/**
* Sets the condition for this goal. The condition is checked before the goal is executed.
*
* @param condition The condition to set
*/
public void setCondition(@Nullable SerializablePredicate<NPC> condition)
{
this.condition = condition;
}
/**
* Gets the priority of this goal.
*
* @return The priority of this goal
*/
public final @NotNull Priority getPriority()
{
return priority;
}
/**
* Sets the priority of this goal.
*
* @param priority The new priority for this goal
*/
public final void setPriority(@NotNull Priority priority)
{
this.priority = priority;
}
/**
* Checks whether this goal can be used right now. This is called each tick to determine if the goal should be considered for execution.
*
* @param npc The NPC to check for
* @return true if this goal can be used, false otherwise
*/
protected boolean canUse(@NotNull NPC npc)
{
return condition == null || condition.test(npc);
}
/**
* Called when this goal starts executing. Use this to initialize any state needed for the goal.
*
* @param npc The NPC starting this goal
*/
protected abstract void start(@NotNull NPC npc);
/**
* Called each tick while this goal is active. Use this to update the goal's behavior.
*
* @param npc The NPC executing this goal
*/
protected abstract void tick(@NotNull NPC npc);
/**
* Called when this goal stops executing. Use this to clean up any state or cancel ongoing tasks.
*
* @param npc The NPC stopping this goal
*/
protected abstract void stop(@NotNull NPC npc);
/**
* Checks whether this goal can continue executing. If this returns false, the goal will be stopped and a new goal will be selected.
*
* @param npc The NPC to check for
* @return true if this goal can continue, false otherwise
*/
protected boolean canContinue(@NotNull NPC npc)
{
return condition == null || condition.test(npc);
}
/**
* Checks whether this goal can be interrupted by a new goal selection. If this returns true, the goal selector may switch to a different goal even if this
* goal can continue. This is useful for goals that have natural break points (e.g., waiting periods).
*
* @param npc The NPC to check for
* @return true if this goal can be interrupted, false otherwise
*/
protected boolean canBeInterrupted(@NotNull NPC npc)
{
return false;
}
/**
* Checks whether this goal can be removed immediately without being queued.
* <p>
* If this returns true, the goal can be removed instantly from the goal selector. If this returns false, the goal will be queued for removal and will only
* be removed once this method returns true or the goal naturally completes.
*
* @param npc The NPC to check for
* @return true if the goal can be removed immediately, false if it should be queued
*/
protected boolean canBeRemovedNow(@NotNull NPC npc)
{
return !canContinue(npc);
}
/**
* Creates a copy of this goal.
*
* @return A copy of this goal
*/
public abstract @NotNull Goal copy();
/**
* Priority levels for goal selection. Higher priority goals are more likely to be selected. ALWAYS priority goals are always preferred over other goals.
* Other priorities use weighted random selection where higher weight = higher chance of being selected.
*/
public enum Priority implements Serializable
{
/**
* This goal is always preferred over other goals and will be selected deterministically.
*/
ALWAYS(100),
/**
* Very high chance of being selected when among non-ALWAYS goals.
*/
HIGHEST(4),
/**
* High chance of being selected when among non-ALWAYS goals.
*/
HIGH(3),
/**
* Medium chance of being selected when among non-ALWAYS goals.
*/
MEDIUM(2),
/**
* Low chance of being selected when among non-ALWAYS goals.
*/
LOW(1);
@Serial
private final static long serialVersionUID = 1L;
final int weight;
Priority(int weight)
{
this.weight = weight;
}
}
}
@@ -0,0 +1,339 @@
package de.eisi05.npc.api.ai;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.objects.NPC;
import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
/**
* Manages and selects goals for an NPC. The goal selector evaluates all available goals each tick and selects the highest priority goal that can be used.
*/
public class GoalSelector
{
private final NPC npc;
private final Set<Goal> removalQueue = new HashSet<>();
private Goal currentGoal;
private BukkitTask task;
private boolean running;
private long tickInterval;
/**
* Creates a new GoalSelector for the specified NPC.
*
* @param npc The NPC to manage goals for
*/
public GoalSelector(@NotNull NPC npc)
{
this.npc = npc;
this.tickInterval = 1L;
}
/**
* Adds a goal to this selector.
*
* @param goal The goal to add
* @return This selector for method chaining
*/
public @NotNull GoalSelector addGoal(@NotNull Goal goal)
{
npc.addGoal(goal);
return this;
}
/**
* Removes a goal from this selector.
*
* @param goal The goal to remove
* @return This selector for method chaining
*/
public @NotNull GoalSelector removeGoal(@NotNull Goal goal)
{
npc.removeGoal(goal);
return this;
}
/**
* Stops the goal if it is running
*
* @param goal The goal to check
* @return This selector for method chaining
*/
public @NotNull GoalSelector stopGoalIfRunning(@NotNull Goal goal)
{
if(currentGoal == goal)
{
currentGoal.stop(npc);
currentGoal = null;
}
return this;
}
/**
* Queues a goal for removal. If the goal is currently running, it will be removed immediately if it can be removed now, otherwise it will be queued for
* removal and removed after it finishes naturally. If the goal is not currently running, it will be removed immediately from the NPC's goal list.
*
* @param goal The goal to queue for removal
* @return true if the goal was removed immediately or is not currently running, false if the goal was queued for removal
*/
public boolean queueGoalForRemoval(@NotNull Goal goal)
{
if(currentGoal == goal)
{
if(goal.canBeRemovedNow(npc))
return true;
else
{
removalQueue.add(goal);
return false;
}
}
else
return true;
}
/**
* Checks if a goal is queued for removal.
* <p>
* Goals that are currently running cannot be removed immediately and are instead queued for removal once they finish naturally. This method allows checking
* whether a specific goal is in that removal queue.
*
* @param goal The goal to check
* @return true if the goal is queued for removal, false otherwise
*/
public boolean isGoalQueuedForRemoval(@NotNull Goal goal)
{
return removalQueue.contains(goal);
}
/**
* Gets all goals registered with this selector.
*
* @return A list of all goals
*/
public @NotNull ArrayList<Goal> getGoals()
{
return new ArrayList<>(npc.getGoals());
}
/**
* Gets the currently active goal.
*
* @return The current goal, or null if no goal is active
*/
public @Nullable Goal getCurrentGoal()
{
return currentGoal;
}
/**
* Starts the goal selector. This will begin evaluating and executing goals.
*/
public void start()
{
if(running)
return;
running = true;
task = Bukkit.getScheduler().runTaskTimer(NpcApi.plugin, this::tick, 0L, tickInterval);
}
/**
* Stops the goal selector. This will stop the current goal and cease evaluation.
*/
public void stop()
{
if(!running)
return;
running = false;
if(task != null)
{
task.cancel();
task = null;
}
if(currentGoal != null)
{
currentGoal.stop(npc);
currentGoal = null;
}
}
/**
* Checks whether the goal selector is currently running.
*
* @return true if running, false otherwise
*/
public boolean isRunning()
{
return running;
}
/**
* Gets the current tick interval.
*
* @return The tick interval in ticks
*/
public long getTickInterval()
{
return tickInterval;
}
/**
* Sets the tick interval for goal evaluation.
*
* @param ticks The number of ticks between evaluations (1 = every tick)
*/
public void setTickInterval(long ticks)
{
this.tickInterval = ticks;
if(running)
{
stop();
start();
}
}
/**
* Manually forces a specific goal to start, bypassing normal selection. This is useful for triggering goals based on external events.
*
* @param goal The goal to force start
*/
public void forceGoal(@NotNull Goal goal)
{
if(!getGoals().contains(goal))
return;
if(currentGoal != null)
currentGoal.stop(npc);
currentGoal = goal;
currentGoal.start(npc);
}
/**
* Processes the removal queue, removing goals that have finished running.
*/
private void processRemovalQueue()
{
if(removalQueue.isEmpty())
return;
List<Goal> copy = new ArrayList<>(removalQueue);
for(Goal goal : copy)
{
if(goal != null && removalQueue.contains(goal) && goal.canBeRemovedNow(npc))
{
removalQueue.remove(goal);
npc.removeGoal(goal);
if(goal == currentGoal)
currentGoal = null;
}
}
}
/**
* The main tick method that evaluates and executes goals.
*/
private void tick()
{
processRemovalQueue();
List<Goal> usableGoals = getGoals().stream()
.filter(goal -> goal.canUse(npc))
.toList();
if(usableGoals.isEmpty())
{
if(currentGoal != null)
{
currentGoal.stop(npc);
currentGoal = null;
}
return;
}
List<Goal> alwaysGoals = usableGoals.stream()
.filter(goal -> goal.getPriority() == Goal.Priority.ALWAYS)
.toList();
Goal selectedGoal;
if(!alwaysGoals.isEmpty())
selectedGoal = alwaysGoals.get(ThreadLocalRandom.current().nextInt(alwaysGoals.size()));
else
{
if(usableGoals.isEmpty())
selectedGoal = null;
else if(usableGoals.size() == 1)
selectedGoal = usableGoals.get(0);
else
{
int totalWeight = usableGoals.stream()
.mapToInt(goal -> goal.getPriority().weight)
.sum();
if(totalWeight == 0)
selectedGoal = usableGoals.get(ThreadLocalRandom.current().nextInt(usableGoals.size()));
else
{
int randomWeight = ThreadLocalRandom.current().nextInt(totalWeight);
int currentWeight = 0;
selectedGoal = null;
for(Goal goal : usableGoals)
{
currentWeight += goal.getPriority().weight;
if(randomWeight < currentWeight)
{
selectedGoal = goal;
break;
}
}
if(selectedGoal == null)
selectedGoal = usableGoals.get(usableGoals.size() - 1);
}
}
}
if(currentGoal != null)
{
if(selectedGoal != null && selectedGoal != currentGoal)
{
if(selectedGoal.getPriority() == Goal.Priority.ALWAYS || currentGoal.canBeInterrupted(npc))
{
currentGoal.stop(npc);
currentGoal = selectedGoal;
currentGoal.start(npc);
return;
}
}
if(currentGoal.canContinue(npc))
{
currentGoal.tick(npc);
return;
}
else
{
currentGoal.stop(npc);
currentGoal = null;
}
}
if(selectedGoal != null)
{
currentGoal = selectedGoal;
currentGoal.start(npc);
}
}
}
@@ -0,0 +1,901 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.utils.LocationUtils;
import de.eisi05.npc.api.utils.Reflections;
import de.eisi05.npc.api.utils.SerializableBiPredicate;
import de.eisi05.npc.api.wrapper.enums.Pose;
import de.eisi05.npc.api.wrapper.objects.WrappedEntityData;
import de.eisi05.npc.api.wrapper.objects.WrappedServerPlayer;
import de.eisi05.npc.api.wrapper.packets.AnimatePacket;
import de.eisi05.npc.api.wrapper.packets.PacketWrapper;
import de.eisi05.npc.api.wrapper.packets.RemoveEntityPacket;
import de.eisi05.npc.api.wrapper.packets.SetEntityDataPacket;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeModifier;
import org.bukkit.entity.*;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.io.Serial;
import java.util.*;
/**
* A goal that makes the NPC attack nearby entities that match a predicate. The attack behavior varies based on the item in the NPC's main hand: -
* Bow/Trident/Crossbow: Long range attacks - Sword/Axe: Short range attacks - Other: Short range attacks Only activates when a valid target is in range and
* line of sight. Once a target is locked, the NPC continues attacking until the target becomes unreachable or invalid.
*/
public class AttackEntityGoal extends Goal
{
@Serial
private static final long serialVersionUID = 1L;
private static final double WEAPONMECHANICS_ATTACK_RANGE = 25.0;
private static final double BOW_ATTACK_RANGE = 15.0;
private static final double MELEE_ATTACK_RANGE = 3.0;
private static final double LINE_OF_SIGHT_RANGE = 25.0;
private static final int BOW_DRAW_DELAY_TICKS = 25;
private static final int WEAPONMECHANICS_SHOOT_DELAY_TICKS = 5;
private static final double KITING_DISTANCE = 3.0;
private static final double OPTIMAL_RANGED_DISTANCE = 6.0;
private SerializableBiPredicate<LivingEntity, NPC> targetFilter;
private double customAttackRange;
private double speed;
private transient LivingEntity target;
private transient int attackCooldown;
private transient boolean isAttacking;
private transient WalkToLocationGoal movementGoal;
private transient boolean isUsing;
private transient List<Player> cachedViewers;
private transient int lineOfSightCheckCooldown;
private transient int pathRecalculationCooldown;
private transient boolean isKiting;
/**
* Creates an AttackEntityGoal with a filter for valid targets.
*
* @param targetFilter A predicate to filter valid targets
*/
public AttackEntityGoal(@NotNull SerializableBiPredicate<LivingEntity, NPC> targetFilter)
{
this(targetFilter, -1);
}
/**
* Creates an AttackEntityGoal with a filter for valid targets and custom attack range.
*
* @param targetFilter A predicate to filter valid targets
* @param customAttackRange Custom attack range (-1 for default based on weapon)
*/
public AttackEntityGoal(@NotNull SerializableBiPredicate<LivingEntity, NPC> targetFilter, double customAttackRange)
{
this(targetFilter, customAttackRange, WalkToLocationGoal.DEFAULT_SPEED);
}
/**
* Creates an AttackEntityGoal with a filter for valid targets and custom speed.
*
* @param targetFilter A predicate to filter valid targets
* @param speed Custom movement speed (-1 for default)
*/
public AttackEntityGoal(@NotNull SerializableBiPredicate<LivingEntity, NPC> targetFilter, double customAttackRange, double speed)
{
super(Priority.ALWAYS);
this.targetFilter = targetFilter;
this.customAttackRange = customAttackRange;
this.speed = Math.max(0.1, Math.min(1.0, speed));
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private AttackEntityGoal(@NotNull AttackEntityGoal goal)
{
super(goal.getPriority());
this.targetFilter = goal.targetFilter;
this.customAttackRange = goal.customAttackRange;
this.speed = goal.speed;
}
/**
* Gets the target filter for this goal.
*
* @return the target filter
*/
public SerializableBiPredicate<LivingEntity, NPC> getTargetFilter()
{
return targetFilter;
}
/**
* Sets the target filter for this goal.
*
* @param targetFilter the new target filter
*/
public void setTargetFilter(@NotNull SerializableBiPredicate<LivingEntity, NPC> targetFilter)
{
this.targetFilter = targetFilter;
}
/**
* Gets the custom attack range for this goal.
*
* @return the custom attack range
*/
public double getCustomAttackRange()
{
return customAttackRange;
}
/**
* Sets the custom attack range for this goal.
*
* @param customAttackRange the new custom attack range
*/
public void setCustomAttackRange(double customAttackRange)
{
this.customAttackRange = customAttackRange;
}
/**
* Gets the speed for this goal.
*
* @return the speed
*/
public double getSpeed()
{
return speed;
}
/**
* Sets the speed for this goal.
*
* @param speed the new speed
*/
public void setSpeed(double speed)
{
this.speed = speed;
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true if a valid target is found or an existing target is still valid
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
if(targetFilter == null)
return false;
if(!super.canUse(npc))
return false;
if(target != null && target.isValid() && canContinue(npc))
return true;
LivingEntity potentialTarget = findTarget(npc);
if(potentialTarget == null)
return false;
this.target = potentialTarget;
return true;
}
/**
* Starts the attack goal, initializing all necessary state.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
this.attackCooldown = 0;
this.isAttacking = true;
movementGoal = null;
isUsing = false;
lineOfSightCheckCooldown = 0;
pathRecalculationCooldown = 0;
isKiting = false;
setUsingItemState(npc, false);
updateCachedViewers(npc);
}
/**
* Ticks the attack goal, handling movement, targeting, and attacks.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
if(target == null || !target.isValid())
{
isAttacking = false;
stopMovement(npc);
return;
}
Location npcLoc = npc.getLocation();
if(cachedViewers == null || cachedViewers.size() != npc.getViewers().size())
updateCachedViewers(npc);
for(Player viewer : cachedViewers)
npc.lookAtEntity(target, viewer, true);
if(lineOfSightCheckCooldown > 0)
lineOfSightCheckCooldown--;
double distance = npcLoc.distance(target.getLocation());
double attackRange = getAttackRange(npc);
if(distance > attackRange)
{
boolean checkLineOfSight = lineOfSightCheckCooldown <= 0;
if(checkLineOfSight)
lineOfSightCheckCooldown = 5;
if(distance > LINE_OF_SIGHT_RANGE || (checkLineOfSight && !hasLineOfSight(npc, target)))
{
stop(npc);
return;
}
if(movementGoal == null)
startMovement(npc, target.getLocation());
else
{
Location currentTarget = movementGoal.getTargetLocation();
boolean shouldRecalculate = currentTarget.distance(target.getLocation()) > 5.0;
if(!shouldRecalculate && pathRecalculationCooldown <= 0)
{
shouldRecalculate = true;
pathRecalculationCooldown = WalkToLocationGoal.RECALCULATION_COOLDOWN;
}
if(shouldRecalculate)
{
startMovement(npc, target.getLocation());
pathRecalculationCooldown = WalkToLocationGoal.RECALCULATION_COOLDOWN;
}
else
{
movementGoal.tick(npc);
pathRecalculationCooldown--;
}
distance = npc.getLocation().distance(target.getLocation());
if(distance <= attackRange)
stopMovement(npc);
else
return;
}
}
else if(isUsingRangedWeapon(npc))
{
if(distance < KITING_DISTANCE && !isKiting)
startKiting(npc);
else if(isKiting && distance >= OPTIMAL_RANGED_DISTANCE)
stopKiting(npc);
else if(isKiting)
{
if(movementGoal != null)
{
movementGoal.tick(npc);
pathRecalculationCooldown--;
if(pathRecalculationCooldown <= 0)
{
startKiting(npc);
pathRecalculationCooldown = WalkToLocationGoal.RECALCULATION_COOLDOWN;
}
}
else
isKiting = false;
return;
}
else
stopMovement(npc);
}
else
stopMovement(npc);
if(attackCooldown > 0)
{
attackCooldown--;
return;
}
if(distance <= attackRange)
{
performAttack(npc);
attackCooldown = getAttackCooldown(npc);
}
}
/**
* Stops the attack goal and cleans up state.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
stopMovement(npc);
target = null;
isAttacking = false;
attackCooldown = 0;
isUsing = false;
setUsingItemState(npc, false);
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if the target is still valid and in range
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
if(!isAttacking || target == null || !target.isValid())
return false;
if(!super.canContinue(npc))
return false;
Location npcLoc = npc.getLocation();
Location targetLoc = target.getLocation();
if(!npcLoc.getWorld().equals(targetLoc.getWorld()))
return false;
boolean checkLineOfSight = lineOfSightCheckCooldown <= 0;
if(checkLineOfSight && !hasLineOfSight(npc, target))
return false;
double distance = npcLoc.distance(targetLoc);
double attackRange = getAttackRange(npc);
return distance <= Math.max(attackRange, LINE_OF_SIGHT_RANGE);
}
@Override
protected boolean canBeRemovedNow(@NotNull NPC npc)
{
if(super.canBeRemovedNow(npc))
return true;
if(movementGoal != null && movementGoal.canContinue(npc))
{
Location npcLoc = npc.getLocation();
Location below = npcLoc.clone().subtract(0, 0.1, 0);
if(below.getBlock().getType().isAir())
return false;
}
return true;
}
@Override
public @NotNull Goal copy()
{
return new AttackEntityGoal(this);
}
/**
* Finds a valid target entity near the NPC.
*/
private LivingEntity findTarget(@NotNull NPC npc)
{
Location npcLoc = npc.getLocation();
double searchRange = Math.max(getAttackRange(npc), LINE_OF_SIGHT_RANGE);
for(Entity entity : npcLoc.getWorld().getNearbyEntities(npcLoc, searchRange, searchRange, searchRange))
{
if(!(entity instanceof LivingEntity livingEntity))
continue;
if(!targetFilter.test(livingEntity, npc))
continue;
if(!hasLineOfSight(npc, livingEntity))
continue;
return livingEntity;
}
return null;
}
/**
* Checks if the NPC has line of sight to the target entity.
*/
private boolean hasLineOfSight(@NotNull NPC npc, @NotNull LivingEntity target)
{
Location npcLoc = npc.getLocation().clone().add(0, 1.6, 0);
Location targetLoc = target.getLocation().clone().add(0, target.getEyeHeight(), 0);
Vector direction = targetLoc.toVector().subtract(npcLoc.toVector()).normalize();
double distance = npcLoc.distance(targetLoc);
int steps = (int) (distance * 2);
for(int i = 0; i < steps; i++)
{
Location checkLoc = npcLoc.clone().add(direction.clone().multiply(i * 0.5));
if(checkLoc.getBlock().getType().isSolid() && !checkLoc.getBlock().isPassable())
return false;
}
return true;
}
/**
* Gets the attack range based on the NPC's held item.
*/
private double getAttackRange(@NotNull NPC npc)
{
if(customAttackRange > 0)
return customAttackRange;
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
if(mainHand == null)
return MELEE_ATTACK_RANGE;
if(getWeaponTitle(mainHand) != null)
return WEAPONMECHANICS_ATTACK_RANGE;
if(isRangedWeapon(mainHand.getType()))
return BOW_ATTACK_RANGE;
else
return MELEE_ATTACK_RANGE;
}
/**
* Gets the attack cooldown based on the NPC's held item.
*/
private int getAttackCooldown(@NotNull NPC npc)
{
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
if(mainHand == null || !mainHand.hasItemMeta())
return 5;
String weaponTitle = getWeaponTitle(mainHand);
if(weaponTitle != null)
{
try
{
Object config = Reflections.invokeMethod("me.deecaad.weaponmechanics.WeaponMechanics", "getInstance")
.thanInvoke("getWeaponConfigurations").get();
if(config != null)
{
int shotsPerSecond = (int) Reflections.invokeMethod(config, "getInt", weaponTitle + ".Shoot.Fully_Automatic_Shots_Per_Second", 0).get();
if(shotsPerSecond > 0)
return Math.max(1, 20 / shotsPerSecond);
int burstDelay = (int) Reflections.invokeMethod(config, "getInt", weaponTitle + ".Shoot.Burst.Ticks_Between_Each_Shot", 0).get();
if(burstDelay > 0)
return burstDelay;
int shootDelay = (int) Reflections.invokeMethod(config, "getInt", weaponTitle + ".Shoot.Delay_Between_Shots", 0).get();
if(shootDelay > 0)
return shootDelay;
}
}
catch(NoClassDefFoundError | Exception ignored) {}
return 10;
}
ItemMeta meta = mainHand.getItemMeta();
if(meta == null)
return 5;
Collection<AttributeModifier> modifiers = meta.getAttributeModifiers(Attribute.GENERIC_ATTACK_SPEED);
if(modifiers == null)
return 5;
double speed = 0;
for(AttributeModifier modifier : modifiers)
speed += modifier.getAmount();
return (int) (20 / speed);
}
/**
* Performs an attack on the target.
*/
private void performAttack(@NotNull NPC npc)
{
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
String weaponTitle = getWeaponTitle(mainHand);
if(weaponTitle != null)
performWeaponMechanicsAttack(npc, weaponTitle, mainHand);
else if(mainHand != null && isRangedWeapon(mainHand.getType()))
performRangedAttack(npc);
else
performMeleeAttack(npc);
}
/**
* Utilizes WeaponMechanics API to gather weapon identification title safely.
*/
private String getWeaponTitle(ItemStack item)
{
if(item == null || item.getType() == Material.AIR)
return null;
try
{
return (String) Reflections.invokeMethod("me.deecaad.weaponmechanics.WeaponMechanicsAPI", "getWeaponTitle", item).get();
}
catch (NoClassDefFoundError | Exception e)
{
return null;
}
}
/**
* Executes native programmatic WeaponMechanics shooting actions centered around the NPC.
*/
private void performWeaponMechanicsAttack(@NotNull NPC npc, String weaponTitle, ItemStack weaponStack)
{
if(target == null || !target.isValid())
return;
Player npcPlayer = npc.getServerPlayer().getBukkitPlayer();
if(npcPlayer == null)
return;
if(target == null || !target.isValid())
return;
try
{
Vector direction = target.getLocation().subtract(npc.getLocation()).toVector().normalize();
Location location = npc.getLocation().clone().add(0, npc.getEyeHeight(null), 0);
location.setDirection(direction);
Object entityWrapper = Reflections.invokeMethod("me.deecaad.weaponmechanics.WeaponMechanics", "getInstance")
.thanInvoke("getEntityWrapper", npcPlayer).get();
Reflections.invokeMethod("me.deecaad.weaponmechanics.WeaponMechanics", "getInstance")
.thanInvoke("getWeaponHandler").thanInvoke("getShootHandler")
.thanInvoke("shoot", entityWrapper, weaponTitle, weaponStack, location, true, true, false);
}
catch(NoClassDefFoundError | Exception e)
{
target.damage(getAttackDamage(npc), npcPlayer);
}
}
/**
* Performs a ranged attack.
*/
private void performRangedAttack(@NotNull NPC npc)
{
if(target == null || !target.isValid() || isUsing)
return;
double eyeHeight = (npc.entity.getBukkitPlayer() instanceof LivingEntity le ? le.getEyeHeight() : npc.entity.getBukkitPlayer().getHeight()) -
(Pose.fromBukkit(npc.getOption(NpcOption.POSE)) == Pose.SITTING ? 0.625 : 0);
Location npcLoc = npc.getLocation().clone().add(0, eyeHeight * npc.getOption(NpcOption.SCALE), 0);
final float speed = 2.5f;
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
if(mainHand != null && (mainHand.getType() == Material.BOW || mainHand.getType() == Material.CROSSBOW))
{
setUsingItemState(npc, true);
isUsing = true;
Bukkit.getScheduler().runTaskLater(NpcApi.plugin, () ->
{
setUsingItemState(npc, false);
isUsing = false;
if(target == null)
return;
Location targetLoc = target.getLocation().clone().add(0, target.getEyeHeight(), 0);
Vector direction = targetLoc.toVector().subtract(npcLoc.toVector()).normalize();
AbstractArrow arrow;
if(mainHand == null || mainHand.getType() == Material.TRIDENT)
arrow = npcLoc.getWorld().spawnArrow(npcLoc, direction, speed, 0, Trident.class);
else
arrow = npcLoc.getWorld().spawnArrow(npcLoc, direction, speed, 0, Arrow.class);
arrow.setShooter(npc.getServerPlayer().getBukkitPlayer());
arrow.setDamage(getAttackDamage(npc));
arrow.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
arrow.setShotFromCrossbow(mainHand.getType() == Material.CROSSBOW);
PacketWrapper removePacket = new RemoveEntityPacket(arrow.getEntityId());
for(Player player : npcLoc.getWorld().getPlayers())
{
if(!npc.getViewers().contains(player.getUniqueId()))
WrappedServerPlayer.fromPlayer(player).sendPacket(removePacket);
}
}, BOW_DRAW_DELAY_TICKS);
}
}
/**
* Sets the NPC's item usage state (e.g., drawing a bow).
*
* @param npc the NPC to update
* @param using true if the NPC is using an item, false otherwise
*/
private void setUsingItemState(@NotNull NPC npc, boolean using)
{
WrappedEntityData data = npc.getServerPlayer().getEntityData();
WrappedEntityData.EntityDataAccessor<Byte> accessor = WrappedEntityData.EntityDataSerializers.BYTE.create(8);
Byte handValue = data.get(accessor);
byte handFlag = handValue == null ? 0 : handValue;
handFlag = using ? (byte) (handFlag | 0x01) : (byte) (handFlag & ~0x01);
data.set(accessor, handFlag);
if(cachedViewers == null || cachedViewers.size() != npc.getViewers().size())
updateCachedViewers(npc);
PacketWrapper packetWrapper = SetEntityDataPacket.create(npc.getServerPlayer().getId(), data);
for(Player viewer : cachedViewers)
WrappedServerPlayer.fromPlayer(viewer).sendPacket(packetWrapper);
}
/**
* Performs a melee attack.
*/
private void performMeleeAttack(@NotNull NPC npc)
{
if(target == null || !target.isValid())
return;
if(cachedViewers == null || cachedViewers.size() != npc.getViewers().size())
updateCachedViewers(npc);
for(Player viewer : cachedViewers)
npc.playAnimation(viewer, AnimatePacket.Animation.SWING_MAIN_HAND);
target.damage(getAttackDamage(npc));
Location npcLoc = npc.getLocation();
Location targetLoc = target.getLocation();
double knockbackStrength = getAttackKnockback(npc);
double knockbackResistance = getKnockbackResistance(target);
double effectiveKnockback = knockbackStrength * (1 - knockbackResistance);
Vector direction = targetLoc.toVector().subtract(npcLoc.toVector());
if(direction.lengthSquared() > Vector.getEpsilon())
direction = direction.normalize();
Vector knockback = direction.multiply(effectiveKnockback);
knockback.setY(effectiveKnockback * 0.4);
target.setVelocity(knockback);
}
/**
* Checks if a material is a ranged weapon.
*/
private boolean isRangedWeapon(@NotNull Material material)
{
return material == Material.BOW || material == Material.CROSSBOW || material == Material.TRIDENT;
}
/**
* Checks if the NPC is currently using a ranged weapon.
*/
private boolean isUsingRangedWeapon(@NotNull NPC npc)
{
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
return (mainHand != null && isRangedWeapon(mainHand.getType())) || getWeaponTitle(mainHand) != null;
}
/**
* Gets the attack damage based on the NPC's held item.
*/
private double getAttackDamage(@NotNull NPC npc)
{
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
return getAttackDamage(mainHand);
}
/**
* Gets the attack damage from an item stack.
*
* @param item the item to check
* @return the attack damage value
*/
private double getAttackDamage(ItemStack item)
{
if(item == null || !item.hasItemMeta())
return 0.5;
ItemMeta meta = item.getItemMeta();
if(meta == null)
return 0.5;
Collection<AttributeModifier> modifiers = meta.getAttributeModifiers(Attribute.GENERIC_ATTACK_DAMAGE);
if(modifiers == null)
return 0.5;
double damage = 0;
for(AttributeModifier modifier : modifiers)
damage += modifier.getAmount();
return damage == 0 ? 0.5 : damage;
}
/**
* Gets the attack knockback strength from the NPC's held item.
*
* @param npc the NPC to check
* @return the knockback strength
*/
private double getAttackKnockback(@NotNull NPC npc)
{
Map<EquipmentSlot, ItemStack> equipment = npc.getOption(NpcOption.EQUIPMENT);
ItemStack mainHand = equipment != null ? equipment.get(EquipmentSlot.HAND) : null;
return getAttackKnockback(mainHand);
}
/**
* Gets the attack knockback strength from an item stack.
*
* @param item the item to check
* @return the knockback strength
*/
private double getAttackKnockback(ItemStack item)
{
if(item == null || !item.hasItemMeta())
return 0.5;
ItemMeta meta = item.getItemMeta();
if(meta == null)
return 0.5;
Collection<AttributeModifier> modifiers = meta.getAttributeModifiers(Attribute.GENERIC_ATTACK_KNOCKBACK);
if(modifiers == null)
return 0.5;
double knockback = 0;
for(AttributeModifier modifier : modifiers)
knockback += modifier.getAmount();
return knockback == 0 ? 0.5 : knockback;
}
/**
* Gets the knockback resistance of a target entity.
*
* @param target the entity to check
* @return the knockback resistance value (0-1)
*/
private double getKnockbackResistance(@NotNull LivingEntity target)
{
if(target.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE) == null)
return 0;
return target.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE).getValue();
}
/**
* Starts movement towards the target location.
*/
private void startMovement(@NotNull NPC npc, @NotNull Location targetLoc)
{
stopMovement(npc);
OptionalInt safeY = LocationUtils.findSafeY(targetLoc);
if(safeY.isPresent())
targetLoc = new Location(targetLoc.getWorld(), targetLoc.getX(), safeY.getAsInt(), targetLoc.getZ());
movementGoal = new WalkToLocationGoal.Builder(targetLoc).speed(speed).build();
movementGoal.start(npc);
}
/**
* Starts kiting by moving away from the target to optimal distance.
*/
private void startKiting(@NotNull NPC npc)
{
if(target == null || !target.isValid())
return;
Location npcLoc = npc.getLocation();
Location targetLoc = target.getLocation();
Vector direction = npcLoc.toVector().subtract(targetLoc.toVector()).normalize();
Vector retreatPos = targetLoc.toVector().add(direction.multiply(OPTIMAL_RANGED_DISTANCE));
retreatPos.setX(retreatPos.getX() + (Math.random() - 0.5) * 3);
retreatPos.setZ(retreatPos.getZ() + (Math.random() - 0.5) * 3);
Location retreatLoc = new Location(npcLoc.getWorld(), retreatPos.getX(), npcLoc.getY(), retreatPos.getZ());
OptionalInt safeY = LocationUtils.findSafeY(retreatLoc);
if(safeY.isPresent())
retreatLoc = new Location(retreatLoc.getWorld(), retreatLoc.getX(), safeY.getAsInt(), retreatLoc.getZ());
stopMovement(npc);
WalkToLocationGoal newGoal = new WalkToLocationGoal.Builder(retreatLoc).speed(speed + 0.5).maxIterations(5000).allowDiagonal(true)
.completionCallback(walkingResult ->
{
isKiting = false;
movementGoal = null;
}).withRotation(false).build();
movementGoal = newGoal;
newGoal.start(npc);
isKiting = true;
pathRecalculationCooldown = WalkToLocationGoal.RECALCULATION_COOLDOWN;
}
/**
* Stops kiting and prepares to attack.
*/
private void stopKiting(@NotNull NPC npc)
{
isKiting = false;
stopMovement(npc);
attackCooldown = 0;
}
/**
* Updates the cached viewers list.
*/
private void updateCachedViewers(@NotNull NPC npc)
{
cachedViewers = npc.getViewers().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
}
/**
* Stops any ongoing movement.
*/
private void stopMovement(@NotNull NPC npc)
{
if(movementGoal != null)
{
movementGoal.stop(npc);
movementGoal = null;
}
}
}
@@ -0,0 +1,392 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.utils.LocationUtils;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.Serial;
import java.util.List;
import java.util.Objects;
import java.util.OptionalInt;
import java.util.UUID;
/**
* A goal that makes the NPC follow a target entity. The NPC will maintain a specified distance from the target and pathfind to them if too far.
*/
public class FollowEntityGoal extends Goal
{
@Serial
private static final long serialVersionUID = 1L;
public static final double DEFAULT_FOLLOW_DISTANCE = 10.0;
public static final double DEFAULT_STOP_DISTANCE = 1.5;
private double followDistance;
private double stopDistance;
private double speed;
private UUID targetEntityId;
private transient WalkToLocationGoal currentWalkGoal;
private transient LivingEntity target;
private transient int pathRecalculationCooldown;
private transient Location lastTargetLocation;
private transient List<Player> cachedViewers;
/**
* Creates a FollowEntityGoal with a fixed target entity ID and default distances.
*
* @param targetEntityId The UUID of the entity to follow
*/
public FollowEntityGoal(@NotNull UUID targetEntityId)
{
this(targetEntityId, DEFAULT_FOLLOW_DISTANCE, DEFAULT_STOP_DISTANCE, WalkToLocationGoal.DEFAULT_SPEED);
}
/**
* Creates a FollowEntityGoal with a fixed target entity ID and full configuration.
*
* @param targetEntityId The UUID of the entity to follow
* @param followDistance The distance at which the NPC will start following
* @param stopDistance The distance at which the NPC will stop moving
* @param speed The walking speed
*/
public FollowEntityGoal(@NotNull UUID targetEntityId, double followDistance, double stopDistance, double speed)
{
super(Priority.ALWAYS);
this.targetEntityId = targetEntityId;
this.followDistance = Math.max(stopDistance + 0.5, followDistance);
this.stopDistance = Math.max(0.5, stopDistance);
this.speed = Math.max(0.1, Math.min(1.0, speed));
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private FollowEntityGoal(@NotNull FollowEntityGoal goal)
{
super(goal.getPriority());
this.targetEntityId = goal.targetEntityId;
this.followDistance = goal.followDistance;
this.stopDistance = goal.stopDistance;
this.speed = goal.speed;
}
/**
* Gets the follow distance for this goal.
*
* @return the follow distance
*/
public double getFollowDistance()
{
return followDistance;
}
/**
* Sets the follow distance for this goal.
*
* @param followDistance the new follow distance
*/
public void setFollowDistance(double followDistance)
{
this.followDistance = followDistance;
}
/**
* Gets the stop distance for this goal.
*
* @return the stop distance
*/
public double getStopDistance()
{
return stopDistance;
}
/**
* Sets the stop distance for this goal.
*
* @param stopDistance the new stop distance
*/
public void setStopDistance(double stopDistance)
{
this.stopDistance = stopDistance;
}
/**
* Gets the speed for this goal.
*
* @return the speed
*/
public double getSpeed()
{
return speed;
}
/**
* Sets the speed for this goal.
*
* @param speed the new speed
*/
public void setSpeed(double speed)
{
this.speed = speed;
}
/**
* Gets the target entity ID for this goal.
*
* @return the target entity ID
*/
public @Nullable UUID getTargetEntityId()
{
return targetEntityId;
}
/**
* Sets the target entity ID for this goal.
*
* @param targetEntityId the new target entity ID
*/
public void setTargetEntityId(@NotNull UUID targetEntityId)
{
this.targetEntityId = targetEntityId;
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true if a valid target exists within follow distance
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
if(targetEntityId == null)
return false;
if(!super.canUse(npc))
return false;
LivingEntity potentialTarget = target;
if(potentialTarget == null || !potentialTarget.isValid())
{
Entity entity = Bukkit.getEntity(targetEntityId);
if(!(entity instanceof LivingEntity le))
return false;
potentialTarget = le;
}
if(!potentialTarget.isValid())
return false;
Location npcLoc = npc.getLocation();
Location targetLoc = potentialTarget.getLocation();
if(!npcLoc.getWorld().equals(targetLoc.getWorld()))
return false;
double distance = npcLoc.distance(targetLoc);
return distance <= followDistance && distance > stopDistance;
}
/**
* Starts the follow goal, initializing the target and path.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
Entity entity = Bukkit.getEntity(targetEntityId);
if(!(entity instanceof LivingEntity le))
return;
this.target = le;
this.pathRecalculationCooldown = 0;
this.lastTargetLocation = target.getLocation().clone();
if(target != null && target.isValid())
{
currentWalkGoal = new WalkToLocationGoal.Builder(target.getLocation()).speed(speed).withRotation(false).build();
currentWalkGoal.start(npc);
}
updateCachedViewers(npc);
}
/**
* Ticks the follow goal, updating movement and path recalculation.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
if(target == null || !target.isValid())
{
Entity entity = Bukkit.getEntity(targetEntityId);
if(entity instanceof LivingEntity newTarget && newTarget != null && newTarget.isValid())
target = newTarget;
else
{
stop(npc);
return;
}
}
Location targetLoc = target.getLocation().clone();
if(cachedViewers == null || cachedViewers.size() != npc.getViewers().size())
updateCachedViewers(npc);
for(Player viewer : cachedViewers)
npc.lookAtEntity(target, viewer, true);
targetLoc.setYaw(npc.getLocation().getYaw());
targetLoc.setPitch(npc.getLocation().getPitch());
OptionalInt safeY = LocationUtils.findSafeY(targetLoc);
targetLoc.setY(safeY.isPresent() ? safeY.getAsInt() : targetLoc.getY());
double distance = npc.getLocation().distance(targetLoc);
if(distance <= stopDistance)
{
if(currentWalkGoal != null)
{
currentWalkGoal.stop(npc);
currentWalkGoal = null;
}
return;
}
if(currentWalkGoal == null)
{
currentWalkGoal = new WalkToLocationGoal.Builder(targetLoc).speed(speed).withRotation(false).build();
currentWalkGoal.start(npc);
pathRecalculationCooldown = 10;
lastTargetLocation = targetLoc.clone();
}
else
{
Location currentTarget = currentWalkGoal.getTargetLocation();
boolean shouldRecalculate = currentTarget.distance(targetLoc) > 2.0;
if(!shouldRecalculate && lastTargetLocation != null)
{
double verticalChange = Math.abs(targetLoc.getY() - lastTargetLocation.getY());
if(verticalChange > 0.5)
shouldRecalculate = true;
}
if(!shouldRecalculate && pathRecalculationCooldown <= 0)
{
shouldRecalculate = true;
pathRecalculationCooldown = 10;
}
if(shouldRecalculate)
{
currentWalkGoal.stop(npc);
currentWalkGoal = new WalkToLocationGoal.Builder(targetLoc).speed(speed).withRotation(false).build();
currentWalkGoal.start(npc);
pathRecalculationCooldown = 10;
lastTargetLocation = targetLoc.clone();
}
else
{
currentWalkGoal.tick(npc);
pathRecalculationCooldown--;
}
}
}
/**
* Stops the follow goal and cleans up state.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
if(currentWalkGoal != null)
{
currentWalkGoal.stop(npc);
currentWalkGoal = null;
}
if(cachedViewers == null || cachedViewers.size() != npc.getViewers().size())
updateCachedViewers(npc);
for(Player viewer : cachedViewers)
npc.lookAtEntity(target, viewer, true);
target = null;
lastTargetLocation = null;
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if the target is still valid and within range
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
if(target == null || !target.isValid())
return false;
if(!super.canContinue(npc))
return false;
Location npcLoc = npc.getLocation();
Location targetLoc = target.getLocation();
if(!npcLoc.getWorld().equals(targetLoc.getWorld()))
return false;
double distance = npcLoc.distance(targetLoc);
return distance <= followDistance * 2 && distance > stopDistance;
}
@Override
protected boolean canBeRemovedNow(@NotNull NPC npc)
{
if(super.canBeRemovedNow(npc))
return true;
if(currentWalkGoal != null && currentWalkGoal.canContinue(npc))
{
Location npcLoc = npc.getLocation();
Location below = npcLoc.clone().subtract(0, 0.1, 0);
if(below.getBlock().getType().isAir())
return false;
}
return true;
}
@Override
public @NotNull Goal copy()
{
return new FollowEntityGoal(this);
}
/**
* Updates the cached viewers list.
*/
private void updateCachedViewers(@NotNull NPC npc)
{
cachedViewers = npc.getViewers().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
}
}
@@ -0,0 +1,328 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.wrapper.enums.Pose;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.jetbrains.annotations.NotNull;
import java.io.Serial;
import java.util.concurrent.ThreadLocalRandom;
/**
* A goal that makes the NPC look around randomly. This can serve as a "wait" goal or an idle behavior when no other goals are active.
*/
public class LookAroundGoal extends Goal
{
@Serial
private static final long serialVersionUID = 1L;
public static final int DEFAULT_MIN_DURATION = 20; // 1 second
public static final int DEFAULT_MAX_DURATION = 80; // 4 seconds
private static final float YAW_CHANGE_MAX = 120f;
private static final float PITCH_CHANGE_MAX = 40f;
private static final double ENTITY_LOOK_RANGE = 8.0;
private static final double ENTITY_LOOK_CHANCE = 0.25;
private static final double STARING_CHANCE = 0.2;
private static final float PITCH_MIN_MAX = 50f;
private static final int MIN_STARE_DURATION = 60; // 2 seconds
private static final int MAX_STARE_DURATION = 120; // 5 seconds
private int minDuration;
private int maxDuration;
private transient int ticksRemaining;
private transient float targetYaw;
private transient float targetPitch;
private transient boolean isLooking;
private transient float rotationSpeed;
private transient LivingEntity staringEntity;
/**
* Creates a LookAroundGoal with default duration.
*/
public LookAroundGoal()
{
this(DEFAULT_MIN_DURATION, DEFAULT_MAX_DURATION);
}
/**
* Creates a LookAroundGoal with custom duration range.
*
* @param minDuration Minimum duration in ticks
* @param maxDuration Maximum duration in ticks
*/
public LookAroundGoal(int minDuration, int maxDuration)
{
super(Priority.LOW);
this.minDuration = minDuration;
this.maxDuration = maxDuration;
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private LookAroundGoal(@NotNull LookAroundGoal goal)
{
super(goal.getPriority());
this.minDuration = goal.minDuration;
this.maxDuration = goal.maxDuration;
}
/**
* Gets the maximum duration for this goal.
*
* @return the maximum duration
*/
public int getMaxDuration()
{
return maxDuration;
}
/**
* Sets the maximum duration for this goal.
*
* @param maxDuration the new maximum duration
*/
public void setMaxDuration(int maxDuration)
{
this.maxDuration = maxDuration;
}
/**
* Gets the minimum duration for this goal.
*
* @return the minimum duration
*/
public int getMinDuration()
{
return minDuration;
}
/**
* Sets the minimum duration for this goal.
*
* @param minDuration the new minimum duration
*/
public void setMinDuration(int minDuration)
{
this.minDuration = minDuration;
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true always (this goal can always be used)
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
return minDuration >= 0 && maxDuration >= 0 && super.canUse(npc);
}
/**
* Starts the look around goal, initializing rotation state.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
ticksRemaining = minDuration + ThreadLocalRandom.current().nextInt(maxDuration - minDuration);
isLooking = true;
rotationSpeed = 0.05f + ThreadLocalRandom.current().nextFloat() * 0.15f;
pickNewLookDirection(npc);
}
/**
* Ticks the look around goal, updating head rotation.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
if(ticksRemaining <= 0 && staringEntity == null)
{
decideNextAction(npc);
ticksRemaining = minDuration + ThreadLocalRandom.current().nextInt(maxDuration - minDuration);
}
if(staringEntity != null && ticksRemaining > 0)
lookAtEntity(npc, staringEntity);
else if(staringEntity != null)
staringEntity = null;
Location currentLoc = npc.getLocation();
float currentYaw = currentLoc.getYaw();
float currentPitch = currentLoc.getPitch();
float newYaw, newPitch;
if(staringEntity != null && ticksRemaining > 0)
{
newYaw = targetYaw;
newPitch = targetPitch;
}
else
{
newYaw = lerpAngle(currentYaw, targetYaw, rotationSpeed);
newPitch = lerp(currentPitch, targetPitch, rotationSpeed);
}
newPitch = Math.max(-PITCH_MIN_MAX, Math.min(PITCH_MIN_MAX, newPitch));
currentLoc.setYaw(newYaw);
currentLoc.setPitch(newPitch);
npc.rotateHead(newYaw, newPitch);
npc.setLocation(currentLoc);
ticksRemaining--;
}
/**
* Stops the look around goal and cleans up state.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
isLooking = false;
ticksRemaining = 0;
staringEntity = null;
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if the NPC is still looking
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
return isLooking && super.canContinue(npc);
}
@Override
public @NotNull Goal copy()
{
return new LookAroundGoal(this);
}
/**
* Decides the next action: look around randomly, stare at current direction, or look at a nearby entity.
*/
private void decideNextAction(@NotNull NPC npc)
{
if(ThreadLocalRandom.current().nextDouble() < ENTITY_LOOK_CHANCE)
{
LivingEntity nearbyEntity = findNearbyEntity(npc);
if(nearbyEntity != null)
{
staringEntity = nearbyEntity;
ticksRemaining = MIN_STARE_DURATION + ThreadLocalRandom.current().nextInt(MAX_STARE_DURATION - MIN_STARE_DURATION);
lookAtEntity(npc, nearbyEntity);
rotationSpeed = 0.1f + ThreadLocalRandom.current().nextFloat() * 0.1f;
return;
}
}
if(ThreadLocalRandom.current().nextDouble() < STARING_CHANCE)
rotationSpeed = 0.02f + ThreadLocalRandom.current().nextFloat() * 0.03f;
else
{
pickNewLookDirection(npc);
rotationSpeed = 0.05f + ThreadLocalRandom.current().nextFloat() * 0.15f;
}
}
/**
* Finds a nearby living entity to look at.
*/
private LivingEntity findNearbyEntity(@NotNull NPC npc)
{
Location npcLoc = npc.getLocation();
for(Entity entity : npcLoc.getWorld().getNearbyEntities(npcLoc, ENTITY_LOOK_RANGE, ENTITY_LOOK_RANGE, ENTITY_LOOK_RANGE))
{
if(entity instanceof LivingEntity livingEntity && !entity.equals(npc.entity.getBukkitPlayer()))
return livingEntity;
}
return null;
}
/**
* Sets the target direction to look at an entity.
*/
private void lookAtEntity(@NotNull NPC npc, @NotNull LivingEntity entity)
{
Location npcLoc = npc.getLocation();
Location targetLoc = entity.getLocation();
double dx = targetLoc.getX() - npcLoc.getX();
double eyeHeight = (npc.entity.getBukkitPlayer() instanceof LivingEntity le ? le.getEyeHeight() :
npc.entity.getBukkitPlayer().getHeight()) - (Pose.fromBukkit(npc.getOption(NpcOption.POSE)) == Pose.SITTING ? 0.625 : 0);
double dy = (targetLoc.getY() + entity.getEyeHeight()) - (npcLoc.getY() + (eyeHeight * npc.getOption(NpcOption.SCALE)));
double dz = targetLoc.getZ() - npcLoc.getZ();
double distanceXZ = Math.sqrt(dx * dx + dz * dz);
targetYaw = (float) Math.toDegrees(Math.atan2(-dx, dz));
targetPitch = (float) Math.toDegrees(-Math.atan2(dy, distanceXZ));
targetPitch = Math.max(-PITCH_MIN_MAX, Math.min(PITCH_MIN_MAX, targetPitch));
}
/**
* Picks a new random look direction.
*/
private void pickNewLookDirection(@NotNull NPC npc)
{
Location currentLoc = npc.getLocation();
float currentYaw = currentLoc.getYaw();
float currentPitch = currentLoc.getPitch();
float yawMultiplier = ThreadLocalRandom.current().nextDouble() < 0.3 ? 1.5f : 1.0f;
targetYaw = currentYaw + (ThreadLocalRandom.current().nextFloat() * 2 - 1) * YAW_CHANGE_MAX * yawMultiplier;
float pitchUpAvailable = PITCH_MIN_MAX - currentPitch;
float pitchDownAvailable = currentPitch - (-PITCH_MIN_MAX);
float maxPitchChange = Math.min(PITCH_CHANGE_MAX, Math.min(pitchUpAvailable, pitchDownAvailable) * 2f);
if(pitchUpAvailable < 5f)
targetPitch = currentPitch - ThreadLocalRandom.current().nextFloat() * PITCH_CHANGE_MAX;
else if(pitchDownAvailable < 5f)
targetPitch = currentPitch + ThreadLocalRandom.current().nextFloat() * PITCH_CHANGE_MAX;
else
targetPitch = currentPitch + (ThreadLocalRandom.current().nextFloat() * 2 - 1) * maxPitchChange;
}
/**
* Linearly interpolates between two angles, handling wraparound.
*/
private float lerpAngle(float from, float to, float alpha)
{
float diff = to - from;
while(diff < -180)
diff += 360;
while(diff > 180)
diff -= 360;
return from + diff * alpha;
}
/**
* Linearly interpolates between two values.
*/
private float lerp(float from, float to, float alpha)
{
return from + (to - from) * alpha;
}
}
@@ -0,0 +1,142 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.objects.NPC;
import org.jetbrains.annotations.NotNull;
import java.io.Serial;
/**
* A goal that makes the NPC wait/idle for a specified duration. This is useful for creating pauses between actions.
*/
public class WaitGoal extends Goal
{
@Serial
private static final long serialVersionUID = 1L;
private int durationTicks;
private transient int ticksRemaining;
/**
* Creates a WaitGoal with the specified duration.
*
* @param durationTicks The duration to wait in ticks (20 ticks = 1 second)
*/
public WaitGoal(int durationTicks)
{
super(Priority.LOW);
this.durationTicks = Math.max(1, durationTicks);
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private WaitGoal(@NotNull WaitGoal goal)
{
super(goal.getPriority());
this.durationTicks = goal.durationTicks;
}
/**
* Gets the duration in ticks.
*
* @return The duration in ticks
*/
public int getDurationTicks()
{
return durationTicks;
}
/**
* Sets the duration for this goal.
*
* @param durationTicks the new duration in ticks
*/
public void setDurationTicks(int durationTicks)
{
this.durationTicks = Math.max(1, durationTicks);
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true if there are remaining ticks or duration is set
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
return (ticksRemaining > 0 || durationTicks > 0) && super.canUse(npc);
}
/**
* Starts the wait goal, initializing the tick counter.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
ticksRemaining = durationTicks;
}
/**
* Ticks the wait goal, decrementing the remaining ticks.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
ticksRemaining--;
}
/**
* Stops the wait goal and resets the tick counter.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
ticksRemaining = 0;
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if there are remaining ticks
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
return ticksRemaining > 0 && super.canContinue(npc);
}
@Override
public @NotNull Goal copy()
{
return new WaitGoal(this);
}
/**
* Gets the remaining ticks.
*
* @return The remaining ticks
*/
public int getTicksRemaining()
{
return ticksRemaining;
}
/**
* Resets the wait duration.
*/
public void reset()
{
ticksRemaining = durationTicks;
}
}
@@ -0,0 +1,406 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.enums.WalkingResult;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.pathfinding.Path;
import de.eisi05.npc.api.utils.SerializableConsumer;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.Serial;
import java.util.List;
import java.util.Objects;
/**
* A goal that makes the NPC walk along a predefined path of waypoints.
* The NPC will walk the entire path without recalculating, using the pre-calculated path.
*/
public class WalkPathGoal extends Goal
{
@Serial
private static final long serialVersionUID = 1L;
private static final double DEFAULT_SPEED = 0.25;
private static final double START_LOCATION_THRESHOLD = 50.0 * 50.0;
private Path.SerializablePath serializablePath;
private double speed;
private SerializableConsumer<WalkingResult> completionCallback;
private boolean withRotation;
private transient Path path;
private transient boolean isWalking;
private transient WalkToLocationGoal walkToStartGoal;
private transient boolean walkingToStart;
/**
* Creates a WalkPathGoal with full configuration options.
*
* @param builder the builder containing the configuration options
*/
public WalkPathGoal(@NotNull Builder builder)
{
super(Priority.MEDIUM);
this.path = builder.path;
this.serializablePath = path != null ? path.toSerializablePath() : null;
this.speed = Math.max(0.1, Math.min(1.0, builder.speed));
this.completionCallback = builder.completionCallback;
this.withRotation = builder.withRotation;
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private WalkPathGoal(@NotNull WalkPathGoal goal)
{
super(goal.getPriority());
this.serializablePath = goal.serializablePath;
this.speed = goal.speed;
this.completionCallback = goal.completionCallback;
this.withRotation = goal.withRotation;
}
/**
* Gets the path for this goal.
*
* @return the path
*/
public @Nullable Path getPath()
{
return path;
}
/**
* Sets the path for this goal.
*
* @param path the new path
*/
public void setPath(@NotNull Path path)
{
this.path = path;
this.serializablePath = path.toSerializablePath();
}
/**
* Gets the speed for this goal.
*
* @return the speed
*/
public double getSpeed()
{
return speed;
}
/**
* Sets the speed for this goal.
*
* @param speed the new speed
*/
public void setSpeed(double speed)
{
this.speed = Math.max(0.1, Math.min(1.0, speed));
}
/**
* Gets the completion callback for the walk path goal.
*
* @return the completion callback
*/
public @Nullable SerializableConsumer<WalkingResult> getCompletionCallback()
{
return completionCallback;
}
/**
* Sets the completion callback for the walk path goal.
*
* @param completionCallback the new completion callback
*/
public void setCompletionCallback(@Nullable SerializableConsumer<WalkingResult> completionCallback)
{
this.completionCallback = completionCallback;
}
/**
* Gets whether the NPC should rotate to face the target location.
*
* @return true if the NPC should rotate
*/
public boolean isWithRotation()
{
return withRotation;
}
/**
* Sets whether the NPC should rotate to face the target location.
*
* @param withRotation the new rotation setting
*/
public void setWithRotation(boolean withRotation)
{
this.withRotation = withRotation;
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true if the path is valid, start location is close enough, and not already walking
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
if(isWalking)
return true;
if(!super.canUse(npc))
return false;
if(path == null)
return false;
List<Location> waypoints = path.asLocations();
if(waypoints == null || waypoints.isEmpty())
return false;
Location startLocation = waypoints.get(0);
if(!startLocation.getWorld().equals(npc.getLocation().getWorld()))
return false;
double distance = npc.getLocation().distanceSquared(startLocation);
return distance <= START_LOCATION_THRESHOLD;
}
/**
* Starts the walk path goal, first walking to the start location if needed, then walking the entire path.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
if(path == null)
return;
List<Location> waypoints = path.asLocations();
if(waypoints == null || waypoints.isEmpty())
return;
Location startLocation = waypoints.get(0);
double distance = npc.getLocation().distance(startLocation);
isWalking = true;
if(distance > START_LOCATION_THRESHOLD)
{
walkingToStart = true;
walkToStartGoal = new WalkToLocationGoal.Builder(startLocation)
.speed(speed)
.withRotation(withRotation)
.completionCallback(result ->
{
walkToStartGoal = null;
walkingToStart = false;
if(result == WalkingResult.SUCCESS)
walkPath(npc);
else
{
isWalking = false;
if(completionCallback != null)
completionCallback.accept(result);
}
})
.build();
walkToStartGoal.start(npc);
}
else
walkPath(npc);
}
/**
* Walks the actual path after reaching the start location.
*
* @param npc the NPC to walk
*/
private void walkPath(@NotNull NPC npc)
{
if(path == null)
return;
List<Location> waypoints = path.asLocations();
if(waypoints == null || waypoints.isEmpty())
return;
List<Player> viewers = npc.getViewers().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
Location finalLocation = waypoints.get(waypoints.size() - 1);
npc.walkTo(path, speed, true, result ->
{
isWalking = false;
if(completionCallback != null)
completionCallback.accept(result);
if(result == WalkingResult.SUCCESS)
npc.changeRealLocation(finalLocation);
}, withRotation, viewers);
}
/**
* Ticks the walk path goal, handling both walking to start and walking the path.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
if(walkingToStart && walkToStartGoal != null)
{
walkToStartGoal.tick(npc);
}
}
/**
* Stops the walk path goal and cancels current walking.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
if(!isWalking)
return;
if(walkToStartGoal != null)
{
walkToStartGoal.stop(npc);
walkToStartGoal = null;
}
List<Player> viewers = npc.getViewers().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
for(Player viewer : viewers)
{
if(npc.isWalking(viewer))
npc.cancelWalking(viewer);
}
isWalking = false;
walkingToStart = false;
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if still walking and has waypoints remaining
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
return isWalking && super.canContinue(npc);
}
@Override
protected boolean canBeRemovedNow(@NotNull NPC npc)
{
if(super.canBeRemovedNow(npc))
return true;
if(isWalking)
{
Location npcLoc = npc.getLocation();
Location below = npcLoc.clone().subtract(0, 0.1, 0);
if(below.getBlock().getType().isAir())
return false;
}
return true;
}
@Override
public @NotNull Goal copy()
{
return new WalkPathGoal(this);
}
/**
* Builder class for creating WalkPathGoal instances with a fluent API.
*/
public static class Builder
{
private final Path path;
private double speed = DEFAULT_SPEED;
private SerializableConsumer<WalkingResult> completionCallback;
private boolean withRotation = true;
/**
* Creates a new Builder with the required path.
*
* @param path The path to walk along
*/
public Builder(@NotNull Path path)
{
this.path = path;
}
/**
* Sets the walking speed.
*
* @param speed The walking speed (0.1 to 1.0)
* @return this builder for chaining
*/
public Builder speed(double speed)
{
this.speed = Math.max(0.1, Math.min(1.0, speed));
return this;
}
/**
* Sets the completion callback.
*
* @param completionCallback Callback called when path walking completes
* @return this builder for chaining
*/
public Builder completionCallback(@NotNull SerializableConsumer<WalkingResult> completionCallback)
{
this.completionCallback = completionCallback;
return this;
}
/**
* Sets whether rotation packets should be sent.
*
* @param withRotation If true, includes rotation packets in the movement; otherwise only position packets are sent.
* @return this builder for chaining
*/
public Builder withRotation(boolean withRotation)
{
this.withRotation = withRotation;
return this;
}
/**
* Builds the WalkPathGoal instance.
*
* @return A new WalkPathGoal instance
*/
public WalkPathGoal build()
{
return new WalkPathGoal(this);
}
}
}
@@ -0,0 +1,605 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.enums.WalkingResult;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.pathfinding.AStarPathfinder;
import de.eisi05.npc.api.pathfinding.Path;
import de.eisi05.npc.api.pathfinding.PathfindingUtils;
import de.eisi05.npc.api.scheduler.Tasks;
import de.eisi05.npc.api.utils.SerializableConsumer;
import de.eisi05.npc.api.wrapper.objects.WrappedEntity;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
/**
* A goal that makes the NPC walk to a specific location using pathfinding.
*/
public class WalkToLocationGoal extends Goal
{
public static final int DEFAULT_MAX_ITERATIONS = 5000;
public static final double DEFAULT_SPEED = 0.25;
static final int RECALCULATION_COOLDOWN = 20;
@Serial
private static final long serialVersionUID = 1L;
private static final int PATH_CHECK_AHEAD = 5;
private static final long PATHABILITY_CHECK_INTERVAL_MS = 5000;
private Path.SerializablePath.SerializableLocation serializableLocation;
private double speed;
private int maxIterations;
private boolean allowDiagonal;
private SerializableConsumer<WalkingResult> completionCallback;
private boolean withRotation;
private transient Location targetLocation;
private transient CompletableFuture<Path> pathfindingFuture;
private transient Path currentPath;
private transient boolean isWalking;
private transient int pathRecalculationCooldown = 0;
private transient boolean pathable = true;
private transient long lastPathabilityCheckTime = 0;
/**
* Creates a WalkToLocationGoal with full configuration options.
*
* @param builder the builder containing the configuration options
*/
public WalkToLocationGoal(@NotNull Builder builder)
{
super(Priority.MEDIUM);
this.targetLocation = builder.targetLocation.clone();
this.serializableLocation = new Path.SerializablePath.SerializableLocation(targetLocation);
this.speed = Math.max(0.1, Math.min(1.0, builder.speed));
this.maxIterations = builder.maxIterations;
this.allowDiagonal = builder.allowDiagonal;
this.completionCallback = builder.completionCallback;
this.withRotation = builder.withRotation;
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private WalkToLocationGoal(@NotNull WalkToLocationGoal goal)
{
super(goal.getPriority());
this.serializableLocation = goal.serializableLocation;
this.speed = goal.speed;
this.maxIterations = goal.maxIterations;
this.allowDiagonal = goal.allowDiagonal;
this.completionCallback = goal.completionCallback;
this.withRotation = goal.withRotation;
}
/**
* Gets the speed for this goal.
*
* @return the speed
*/
public double getSpeed()
{
return speed;
}
/**
* Sets the speed for this goal.
*
* @param speed the new speed
*/
public void setSpeed(double speed)
{
this.speed = Math.max(0.1, Math.min(1.0, speed));
}
/**
* Gets the maximum number of iterations for pathfinding.
*
* @return the maximum number of iterations
*/
public int getMaxIterations()
{
return maxIterations;
}
/**
* Sets the maximum number of iterations for pathfinding.
*
* @param maxIterations the new maximum number of iterations
*/
public void setMaxIterations(int maxIterations)
{
this.maxIterations = maxIterations;
}
/**
* Gets whether diagonal movement is allowed.
*
* @return true if diagonal movement is allowed
*/
public boolean isAllowDiagonal()
{
return allowDiagonal;
}
/**
* Sets whether diagonal movement is allowed.
*
* @param allowDiagonal the new diagonal movement setting
*/
public void setAllowDiagonal(boolean allowDiagonal)
{
this.allowDiagonal = allowDiagonal;
}
/**
* Gets the completion callback for the walk to location goal.
*
* @return the completion callback
*/
public @Nullable SerializableConsumer<WalkingResult> getCompletionCallback()
{
return completionCallback;
}
/**
* Sets the completion callback for the walk to location goal.
*
* @param completionCallback the new completion callback
*/
public void setCompletionCallback(@Nullable SerializableConsumer<WalkingResult> completionCallback)
{
this.completionCallback = completionCallback;
}
/**
* Gets whether the NPC should rotate to face the target location.
*
* @return true if the NPC should rotate
*/
public boolean isWithRotation()
{
return withRotation;
}
/**
* Sets whether the NPC should rotate to face the target location.
*
* @param withRotation the new rotation setting
*/
public void setWithRotation(boolean withRotation)
{
this.withRotation = withRotation;
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true if the NPC is not already walking and target is valid
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
if(isWalking)
return true;
if(!super.canUse(npc))
return false;
if(targetLocation == null || !targetLocation.getWorld().equals(npc.getLocation().getWorld()))
return false;
if(npc.getLocation().distance(targetLocation) <= 1.0)
return false;
if(lastPathabilityCheckTime == 0 || System.currentTimeMillis() - lastPathabilityCheckTime > PATHABILITY_CHECK_INTERVAL_MS)
checkPathabilityAsync(npc);
if(!pathable)
return false;
return true;
}
/**
* Starts the walk to location goal, calculating the path.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
if(targetLocation == null)
return;
calculatePath(npc);
}
/**
* Ticks the walk to location goal, checking for path validity and recalculating if needed.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
if(pathRecalculationCooldown > 0)
{
pathRecalculationCooldown--;
return;
}
if(isPathInvalid(npc))
{
calculatePath(npc);
pathRecalculationCooldown = RECALCULATION_COOLDOWN;
}
}
/**
* Stops the walk to location goal and cancels pathfinding.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
if(pathfindingFuture != null)
{
pathfindingFuture.cancel(true);
pathfindingFuture = null;
}
cancelWalking(npc);
currentPath = null;
pathable = true;
lastPathabilityCheckTime = 0;
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if the NPC is still walking and not at target
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
return isWalking && currentPath != null && targetLocation != null && npc.getLocation().distance(targetLocation) > 1.0
&& super.canContinue(npc);
}
@Override
protected boolean canBeRemovedNow(@NotNull NPC npc)
{
if(super.canBeRemovedNow(npc))
return true;
if(isWalking)
{
Location npcLoc = npc.getLocation();
Location below = npcLoc.clone().subtract(0, 0.1, 0);
if(below.getBlock().getType().isAir())
return false;
}
return true;
}
@Override
public @NotNull Goal copy()
{
return new WalkToLocationGoal(this);
}
/**
* Gets the target location.
*
* @return The target location
*/
public @NotNull Location getTargetLocation()
{
return targetLocation.clone();
}
/**
* Sets the target location for this goal.
*
* @param targetLocation the new target location
*/
public void setTargetLocation(@NotNull Location targetLocation)
{
this.targetLocation = targetLocation;
this.serializableLocation = new Path.SerializablePath.SerializableLocation(targetLocation);
}
/**
* Asynchronously checks if a path exists to the target location. Updates the pathable cache based on the result.
*
* @param npc the NPC to check pathability for
*/
private void checkPathabilityAsync(@NotNull NPC npc)
{
lastPathabilityCheckTime = System.currentTimeMillis();
Location start = npc.getLocation();
Location end = targetLocation;
if(end == null || !end.getWorld().equals(start.getWorld()))
{
pathable = false;
return;
}
CompletableFuture<Path> future = npc.findPathAsync(List.of(start, end), maxIterations, allowDiagonal, null);
Tasks.trackFuture(future);
future.thenAcceptAsync(path -> pathable = path != null, task -> Bukkit.getScheduler().runTask(NpcApi.plugin, task))
.exceptionally(e ->
{
pathable = false;
return null;
});
}
/**
* Calculates the path to the target location asynchronously.
*
* @param npc the NPC to calculate path for
*/
private void calculatePath(@NotNull NPC npc)
{
Location start = npc.getLocation();
Location end = targetLocation;
if(!end.getWorld().equals(start.getWorld()))
{
if(completionCallback != null)
completionCallback.accept(WalkingResult.CANCELLED);
cancelWalking(npc);
return;
}
isWalking = true;
pathfindingFuture = npc.findPathAsync(List.of(start, end), maxIterations, allowDiagonal, null);
Tasks.trackFuture(pathfindingFuture);
pathfindingFuture.thenAcceptAsync(path ->
{
isWalking = false;
if(path != null)
{
currentPath = path;
startWalking(npc);
}
else
{
cancelWalking(npc);
if(completionCallback != null)
completionCallback.accept(WalkingResult.CANCELLED);
}
}, task -> Bukkit.getScheduler().runTask(NpcApi.plugin, task))
.exceptionally(e ->
{
Bukkit.getScheduler().runTask(NpcApi.plugin, () ->
{
if(completionCallback != null)
completionCallback.accept(WalkingResult.CANCELLED);
cancelWalking(npc);
});
return null;
});
}
/**
* Starts the actual walking animation along the calculated path.
*
* @param npc the NPC to start walking
*/
private void startWalking(@NotNull NPC npc)
{
List<Player> viewers = npc.getViewers().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
npc.walkTo(currentPath, speed, true, result ->
{
isWalking = false;
if(completionCallback != null)
completionCallback.accept(result);
if(result == WalkingResult.SUCCESS)
npc.changeRealLocation(targetLocation);
}, withRotation, viewers);
isWalking = true;
}
/**
* Checks if the current path is invalid due to block changes. Uses similar validation logic to AStarPathfinder for consistency.
*
* @param npc the NPC to check path for
* @return true if the path is invalid and should be recalculated
*/
private boolean isPathInvalid(@NotNull NPC npc)
{
if(currentPath == null || currentPath.getWaypoints().isEmpty())
return true;
Location npcLoc = npc.getLocation();
if(npcLoc.getWorld() == null)
return true;
WrappedEntity.BoundingBox boundingBox = npc.entity.getBoundingBox();
double scale = npc.getOption(NpcOption.SCALE);
int checkAhead = Math.min(PATH_CHECK_AHEAD, currentPath.getWaypoints().size());
for(int i = 0; i < checkAhead; i++)
{
Location waypoint = currentPath.getWaypoints().get(i);
if(!waypoint.getWorld().equals(npcLoc.getWorld()))
return true;
if(!AStarPathfinder.isPositionValid(npcLoc.getWorld(), waypoint.getX(), waypoint.getY(), waypoint.getZ(), boundingBox.getYSize() * scale,
boundingBox.getXSize() * scale))
return true;
}
return false;
}
/**
* Cancels the NPC's walking task for all viewers.
*
* @param npc the NPC to stop walking
*/
private void cancelWalking(@NotNull NPC npc)
{
isWalking = false;
List<Player> viewers = npc.getViewers().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
for(Player viewer : viewers)
{
if(npc.isWalking(viewer))
npc.cancelWalking(viewer);
}
}
/**
* Serializes the goal for storage.
*
* @param out the output stream
* @throws IOException if serialization fails
*/
@Serial
private void writeObject(@NotNull ObjectOutputStream out) throws IOException
{
out.defaultWriteObject();
}
/**
* Deserializes the goal from storage.
*
* @param in the input stream
* @throws IOException if deserialization fails
* @throws ClassNotFoundException if class is not found
*/
@Serial
private void readObject(@NotNull ObjectInputStream in) throws IOException, ClassNotFoundException
{
in.defaultReadObject();
this.targetLocation = serializableLocation.toLocation();
}
// --- Builder Class ---
/**
* Builder class for creating WalkToLocationGoal instances with a fluent API.
*/
public static class Builder
{
private final Location targetLocation;
private double speed = DEFAULT_SPEED;
private int maxIterations = DEFAULT_MAX_ITERATIONS;
private boolean allowDiagonal = true;
private SerializableConsumer<WalkingResult> completionCallback;
private boolean withRotation = true;
/**
* Creates a new Builder with the required target location.
*
* @param targetLocation The location to walk to
*/
public Builder(@NotNull Location targetLocation)
{
this.targetLocation = targetLocation;
}
/**
* Sets the walking speed.
*
* @param speed The walking speed (0.1 to 1.0)
* @return this builder for chaining
*/
public Builder speed(double speed)
{
this.speed = Math.max(0.1, Math.min(1.0, speed));
return this;
}
/**
* Sets the maximum iterations for pathfinding.
*
* @param maxIterations Maximum iterations for pathfinding
* @return this builder for chaining
*/
public Builder maxIterations(int maxIterations)
{
this.maxIterations = maxIterations;
return this;
}
/**
* Sets whether diagonal movement is allowed.
*
* @param allowDiagonal Whether diagonal movement is allowed
* @return this builder for chaining
*/
public Builder allowDiagonal(boolean allowDiagonal)
{
this.allowDiagonal = allowDiagonal;
return this;
}
/**
* Sets the completion callback.
*
* @param completionCallback Callback called when walking completes
* @return this builder for chaining
*/
public Builder completionCallback(@NotNull SerializableConsumer<WalkingResult> completionCallback)
{
this.completionCallback = completionCallback;
return this;
}
/**
* Sets whether rotation packets should be sent.
*
* @param withRotation If true, includes rotation packets in the movement; otherwise only position packets are sent.
* @return this builder for chaining
*/
public Builder withRotation(boolean withRotation)
{
this.withRotation = withRotation;
return this;
}
/**
* Builds the WalkToLocationGoal instance.
*
* @return A new WalkToLocationGoal instance
*/
public WalkToLocationGoal build()
{
return new WalkToLocationGoal(this);
}
}
}
@@ -0,0 +1,300 @@
package de.eisi05.npc.api.ai.goals;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.utils.LocationUtils;
import org.bukkit.Location;
import org.bukkit.World;
import org.jetbrains.annotations.NotNull;
import java.io.Serial;
import java.util.OptionalInt;
import java.util.concurrent.ThreadLocalRandom;
/**
* A goal that makes the NPC wander randomly to nearby locations. The NPC will pick a random location within a specified radius and walk to it.
*/
public class WanderGoal extends Goal
{
@Serial
private static final long serialVersionUID = 1L;
public static final int DEFAULT_RADIUS = 10;
public static final int DEFAULT_MIN_DELAY = 40; // 2 seconds
public static final int DEFAULT_MAX_DELAY = 140; // 7 seconds
private int radius;
private int minDelay;
private int maxDelay;
private double speed;
private transient WalkToLocationGoal currentWalkGoal;
private transient int delayTicks;
private transient Location targetLocation;
/**
* Creates a WanderGoal with default settings.
*/
public WanderGoal()
{
this(DEFAULT_RADIUS, DEFAULT_MIN_DELAY, DEFAULT_MAX_DELAY, WalkToLocationGoal.DEFAULT_SPEED);
}
/**
* Creates a WanderGoal with custom radius and default delay.
*
* @param radius The maximum radius to wander (in blocks)
*/
public WanderGoal(int radius)
{
this(radius, DEFAULT_MIN_DELAY, DEFAULT_MAX_DELAY, WalkToLocationGoal.DEFAULT_SPEED);
}
/**
* Creates a WanderGoal with full configuration.
*
* @param radius The maximum radius to wander (in blocks)
* @param minDelay Minimum delay between wander actions (in ticks)
* @param maxDelay Maximum delay between wander actions (in ticks)
* @param speed The walking speed
*/
public WanderGoal(int radius, int minDelay, int maxDelay, double speed)
{
super(Priority.MEDIUM);
this.radius = radius;
this.minDelay = minDelay;
this.maxDelay = maxDelay;
this.speed = Math.max(0.1, Math.min(1.0, speed));
}
/**
* Creates a copy of this goal.
*
* @param goal the goal to copy
*/
private WanderGoal(@NotNull WanderGoal goal)
{
super(goal.getPriority());
this.radius = goal.radius;
this.minDelay = goal.minDelay;
this.maxDelay = goal.maxDelay;
this.speed = goal.speed;
}
/**
* Gets the radius for this goal.
*
* @return the radius
*/
public int getRadius()
{
return radius;
}
/**
* Sets the radius for this goal.
*
* @param radius the new radius
*/
public void setRadius(int radius)
{
this.radius = radius;
}
/**
* Gets the maximum delay between wander actions.
*
* @return the maximum delay
*/
public int getMaxDelay()
{
return maxDelay;
}
/**
* Sets the maximum delay between wander actions.
*
* @param maxDelay the new maximum delay
*/
public void setMaxDelay(int maxDelay)
{
this.maxDelay = maxDelay;
}
/**
* Gets the minimum delay between wander actions.
*
* @return the minimum delay
*/
public int getMinDelay()
{
return minDelay;
}
/**
* Sets the minimum delay between wander actions.
*
* @param minDelay the new minimum delay
*/
public void setMinDelay(int minDelay)
{
this.minDelay = minDelay;
}
/**
* Gets the speed for this goal.
*
* @return the speed
*/
public double getSpeed()
{
return speed;
}
/**
* Sets the speed for this goal.
*
* @param speed the new speed
*/
public void setSpeed(double speed)
{
this.speed = Math.max(0.1, Math.min(1.0, speed));
}
/**
* Checks if this goal can be used by the NPC.
*
* @param npc the NPC to check
* @return true always (this goal can always be used)
*/
@Override
public boolean canUse(@NotNull NPC npc)
{
return radius >= 0 && minDelay >= 0 && maxDelay >= 0 && speed >= 0 && super.canUse(npc);
}
/**
* Starts the wander goal by picking a new target.
*
* @param npc the NPC starting this goal
*/
@Override
public void start(@NotNull NPC npc)
{
pickNewTarget(npc);
}
/**
* Ticks the wander goal, handling movement and delays.
*
* @param npc the NPC to update
*/
@Override
public void tick(@NotNull NPC npc)
{
if(currentWalkGoal != null)
{
currentWalkGoal.tick(npc);
if(!currentWalkGoal.canContinue(npc))
{
currentWalkGoal.stop(npc);
currentWalkGoal = null;
delayTicks = minDelay + ThreadLocalRandom.current().nextInt(maxDelay - minDelay);
}
return;
}
if(delayTicks > 0)
{
delayTicks--;
return;
}
pickNewTarget(npc);
}
/**
* Stops the wander goal and cleans up state.
*
* @param npc the NPC stopping this goal
*/
@Override
public void stop(@NotNull NPC npc)
{
if(currentWalkGoal != null)
{
currentWalkGoal.stop(npc);
currentWalkGoal = null;
}
delayTicks = 0;
targetLocation = null;
}
/**
* Checks if this goal should continue running.
*
* @param npc the NPC to check
* @return true if a target location is set
*/
@Override
public boolean canContinue(@NotNull NPC npc)
{
return targetLocation != null && super.canContinue(npc);
}
/**
* Checks if this goal can be interrupted by a new goal selection.
*
* @param npc the NPC to check
* @return true if the NPC is currently waiting (delayTicks == 1)
*/
@Override
public boolean canBeInterrupted(@NotNull NPC npc)
{
return delayTicks == 1;
}
@Override
public @NotNull Goal copy()
{
return new WanderGoal(this);
}
/**
* Picks a new random target location near the NPC.
*/
private void pickNewTarget(@NotNull NPC npc)
{
Location currentLoc = npc.getLocation();
World world = currentLoc.getWorld();
if(world == null)
return;
int attempts = 0;
while(attempts < 10)
{
double angle = ThreadLocalRandom.current().nextDouble() * Math.PI * 2;
double distance = ThreadLocalRandom.current().nextDouble() * radius;
double x = currentLoc.getX() + Math.cos(angle) * distance;
double z = currentLoc.getZ() + Math.sin(angle) * distance;
Location potentialLoc = new Location(world, x, currentLoc.getY(), z);
OptionalInt y = LocationUtils.findSafeY(potentialLoc);
if(y.isPresent())
{
targetLocation = new Location(world, x, y.getAsInt(), z);
currentWalkGoal = new WalkToLocationGoal.Builder(targetLocation).speed(speed).build();
currentWalkGoal.start(npc);
return;
}
attempts++;
}
delayTicks = maxDelay;
}
}
@@ -1,7 +1,13 @@
package de.eisi05.npc.api.enums;
public enum WalkingResult
import java.io.Serial;
import java.io.Serializable;
public enum WalkingResult implements Serializable
{
SUCCESS,
CANCELLED
CANCELLED;
@Serial
private static final long serialVersionUID = 1L;
}
@@ -2,7 +2,6 @@ package de.eisi05.npc.api.listeners;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.wrapper.objects.WrappedPlayerTeam;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
@@ -13,12 +12,22 @@ public class ChangeWorldListener implements Listener
@EventHandler
public void onChange(PlayerChangedWorldEvent event)
{
if(!NpcApi.config.autoManageVisibility())
return;
new BukkitRunnable()
{
@Override
public void run()
{
NpcManager.getList().forEach(npc -> npc.showNPCToPlayer(event.getPlayer()));
NpcManager.getList().forEach(npc ->
{
if(npc.getVisibilityManager().shouldShowToPlayer(event.getPlayer().getUniqueId()))
{
npc.showNPCToPlayer(event.getPlayer());
npc.addWalkingViewer(event.getPlayer());
}
});
}
}.runTaskLater(NpcApi.plugin, 10L);
}
@@ -3,9 +3,9 @@ package de.eisi05.npc.api.listeners;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.scheduler.Tasks;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.objects.NpcSkin;
import de.eisi05.npc.api.scheduler.Tasks;
import de.eisi05.npc.api.utils.PacketReader;
import de.eisi05.npc.api.wrapper.objects.WrappedPlayerTeam;
import org.bukkit.event.EventHandler;
@@ -26,6 +26,9 @@ public class ConnectionListener implements Listener
WrappedPlayerTeam.clear(event.getPlayer().getUniqueId());
if(!NpcApi.config.autoManageVisibility())
return;
new BukkitRunnable()
{
@Override
@@ -33,9 +36,13 @@ public class ConnectionListener implements Listener
{
for(NPC npc : new ArrayList<>(NpcManager.getList()))
{
if(!npc.getVisibilityManager().shouldShowToPlayer(event.getPlayer().getUniqueId()))
continue;
npc.showNPCToPlayer(event.getPlayer());
NpcSkin npcSkin = npc.getOption(NpcOption.SKIN);
if(npcSkin == null || npcSkin.isStatic() || npcSkin.getPlaceholder() == null || npc.getOption(NpcOption.USE_PLAYER_SKIN))
npc.addWalkingViewer(event.getPlayer());
NpcSkin npcSkin = npc.getOption(NpcOption.SKIN, event.getPlayer());
if(npcSkin == null || npcSkin.isStatic() || npcSkin.getPlaceholder() == null || npc.getOption(NpcOption.USE_PLAYER_SKIN, event.getPlayer()))
continue;
Tasks.updateSkin(event.getPlayer(), npc, npcSkin);
@@ -54,7 +61,10 @@ public class ConnectionListener implements Listener
for(NPC npc : NpcManager.getList())
{
npc.nameCache.remove(event.getPlayer().getUniqueId());
npc.hideNpcFromPlayer(event.getPlayer());
npc.removeWalkingViewer(event.getPlayer());
if(NpcApi.config.autoManageVisibility())
npc.hideNpcFromPlayer(event.getPlayer());
}
}
}
@@ -0,0 +1,64 @@
package de.eisi05.npc.api.listeners;
import de.eisi05.npc.api.enums.ClickActionType;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.utils.NpcHitboxUtil;
import de.eisi05.npc.api.utils.PacketReader;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.entity.Pose;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.jetbrains.annotations.NotNull;
public class NpcSleepListener implements Listener
{
private static boolean checkForRay(@NotNull Player player, @NotNull ClickActionType type)
{
double rangeSq = 25.0;
Location eye = player.getEyeLocation();
World world = eye.getWorld();
for(NPC npc : NpcManager.getList())
{
Location npcLoc = npc.getLocation();
if(npcLoc.getWorld() != world)
continue;
if(npc.getOption(NpcOption.POSE, player) != Pose.SLEEPING)
continue;
if(npcLoc.distanceSquared(eye) > rangeSq)
continue;
if(NpcHitboxUtil.rayIntersectsNpc(npc, true, player))
{
PacketReader.callNpc(player, npc, type);
return true;
}
}
return false;
}
@EventHandler
public void onInteract(PlayerInteractEvent event)
{
if(event.getHand() == EquipmentSlot.OFF_HAND)
return;
Action action = event.getAction();
Player player = event.getPlayer();
if(action == Action.RIGHT_CLICK_BLOCK || action == Action.RIGHT_CLICK_AIR)
event.setCancelled(checkForRay(player, ClickActionType.RIGHT));
if(action == Action.LEFT_CLICK_BLOCK || action == Action.LEFT_CLICK_AIR)
event.setCancelled(checkForRay(player, ClickActionType.LEFT));
}
}
@@ -0,0 +1,24 @@
package de.eisi05.npc.api.listeners;
import de.eisi05.npc.api.manager.NpcManager;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Entity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.ProjectileHitEvent;
public class ProjectileHitListener implements Listener
{
@EventHandler
public void onProjectileHit(ProjectileHitEvent event)
{
if(!(event.getEntity() instanceof AbstractArrow arrow))
return;
if(!(arrow.getShooter() instanceof Entity entity))
return;
if(NpcManager.fromId(entity.getEntityId()).isPresent())
arrow.remove();
}
}
@@ -1,7 +1,8 @@
package de.eisi05.npc.api.listeners;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.objects.NPC;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.world.ChunkLoadEvent;
@@ -9,6 +10,7 @@ import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.WorldLoadEvent;
import java.util.ArrayList;
import java.util.Collection;
public class WorldLoadListener implements Listener
{
@@ -21,26 +23,42 @@ public class WorldLoadListener implements Listener
@EventHandler
public void onChunkLoad(ChunkLoadEvent event)
{
if(!NpcApi.config.autoManageVisibility())
return;
int chunkX = event.getChunk().getX();
int chunkZ = event.getChunk().getZ();
Collection<Player> players = event.getWorld().getPlayers();
new ArrayList<>(NpcManager.getList())
.stream()
.filter(npc -> npc.getLocation().getWorld().getUID().equals(event.getWorld().getUID()))
.filter(npc -> (npc.getLocation().getBlockX() >> 4) == chunkX && ((npc.getLocation().getBlockZ() >> 4) == chunkZ))
.forEach(NPC::showNpcToAllPlayers);
.forEach(npc ->
players.forEach(player ->
{
if(npc.getVisibilityManager().shouldShowToPlayer(player.getUniqueId()))
{
npc.showNPCToPlayer(player);
npc.addWalkingViewer(player);
}
}));
}
@EventHandler
public void onChunkUnload(ChunkUnloadEvent event)
{
if(!NpcApi.config.autoManageVisibility())
return;
int chunkX = event.getChunk().getX();
int chunkZ = event.getChunk().getZ();
Collection<Player> players = event.getWorld().getPlayers();
new ArrayList<>(NpcManager.getList())
.stream()
.filter(npc -> npc.getLocation().getWorld().getUID().equals(event.getWorld().getUID()))
.filter(npc -> (npc.getLocation().getBlockX() >> 4) == chunkX && ((npc.getLocation().getBlockZ() >> 4) == chunkZ))
.forEach(NPC::hideNpcFromAllPlayers);
.forEach(npc -> players.forEach(npc::hideNpcFromPlayer));
}
}
@@ -4,8 +4,10 @@ import com.mojang.datafixers.util.Either;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.utils.ObjectSaver;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -258,6 +260,15 @@ public class NpcManager
if(npc.getCreatedAt().isBefore(instant))
npc.setEditable(true);
npc.showNpcToAllPlayers();
if(npc.getVisibilityManager().shouldShowToAllPlayers())
npc.showNpcToAllPlayers();
else
npc.getVisibilityManager().getSpecificPlayers().forEach(uuid ->
{
Player player = Bukkit.getPlayer(uuid);
if(player == null)
return;
npc.showNPCToPlayer(player);
});
}
}
@@ -0,0 +1,93 @@
package de.eisi05.npc.api.manager;
import org.jetbrains.annotations.NotNull;
import java.io.Serial;
import java.io.Serializable;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* Manages visibility settings for an NPC, controlling whether it should be shown to all players or only to specific players.
*/
public class NpcVisibilityManager implements Serializable
{
@Serial
private static final long serialVersionUID = 1L;
private final Set<UUID> specificPlayers = new HashSet<>();
private boolean showToAllPlayers = true;
/**
* Sets whether this NPC should be shown to all players (including new ones joining).
*
* @param showToAllPlayers true if the NPC should be shown to all players, false if only to specific players
*/
public void setShowToAllPlayers(boolean showToAllPlayers)
{
this.showToAllPlayers = showToAllPlayers;
}
/**
* Checks if this NPC should be shown to all players.
*
* @return true if the NPC should be shown to all players, false if only to specific players
*/
public boolean shouldShowToAllPlayers()
{
return showToAllPlayers;
}
/**
* Adds a player to the list of specific players who should see this NPC. This has no effect if showToAllPlayers is true.
*
* @param playerUuid the UUID of the player to add
* @return true if the player was added, false if the player was already in the list
*/
public boolean addSpecificPlayer(@NotNull UUID playerUuid)
{
return specificPlayers.add(playerUuid);
}
/**
* Removes a player from the list of specific players who should see this NPC.
*
* @param playerUuid the UUID of the player to remove
* @return true if the player was removed, false if the player was not in the list
*/
public boolean removeSpecificPlayer(@NotNull UUID playerUuid)
{
return specificPlayers.remove(playerUuid);
}
/**
* Checks if a specific player should see this NPC.
*
* @param playerUuid the UUID of the player to check
* @return true if the player should see this NPC, false otherwise
*/
public boolean shouldShowToPlayer(@NotNull UUID playerUuid)
{
return showToAllPlayers || specificPlayers.contains(playerUuid);
}
/**
* Gets the set of specific players who should see this NPC.
*
* @return an immutable set of player UUIDs
*/
public @NotNull Set<UUID> getSpecificPlayers()
{
return Collections.unmodifiableSet(specificPlayers);
}
/**
* Clears all specific players from the list.
*/
public void clearSpecificPlayers()
{
specificPlayers.clear();
}
}
@@ -1,7 +1,6 @@
package de.eisi05.npc.api.movement;
import de.eisi05.npc.api.utils.ObjectSaver;
import de.eisi05.npc.api.utils.Var;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -10,8 +9,6 @@ import java.io.IOException;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/**
@@ -60,7 +57,7 @@ public record MovementRecording(@NotNull ArrayList<MovementData> movements, long
*/
public void saveToFile(@NotNull File file) throws IOException
{
new ObjectSaver(file).write(this);
new ObjectSaver(file).write(this, false);
}
/**
@@ -14,14 +14,11 @@ import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Replays recorded movements on NPCs with precise timing and smooth transitions. Supports different replay modes and provides callbacks for completion.
@@ -34,13 +31,14 @@ public class MovementReplayer
/**
* Starts replaying recorded movements on an NPC.
*
* @param npc The NPC to control
* @param recording The movement recording
* @param speedMultiplier Speed multiplier (speed up or slows down the replay), normal speed = 1
* @param onComplete Callback when replay completes
* @param npc The NPC to control
* @param recording The movement recording
* @param speedMultiplier Speed multiplier (speed up or slows down the replay), normal speed = 1
* @param changeRealLocation Whether to change the real location of the NPC
* @param onComplete Callback when replay completes
* @return The replay session ID
*/
public static long startReplay(@NotNull Function<NPC, NPC> npc, @NotNull MovementRecording recording, double speedMultiplier,
public static long startReplay(@NotNull NPC npc, @NotNull MovementRecording recording, double speedMultiplier, boolean changeRealLocation,
@Nullable Consumer<ReplayResult> onComplete, @NotNull Player... viewers)
{
if(recording.movements().isEmpty())
@@ -51,7 +49,7 @@ public class MovementReplayer
}
long replayId = replayIdCounter.incrementAndGet();
ReplaySession session = new ReplaySession(npc, recording, speedMultiplier, onComplete, replayId, viewers);
ReplaySession session = new ReplaySession(npc, recording, speedMultiplier, changeRealLocation, onComplete, replayId, viewers);
session.start();
return replayId;
@@ -123,25 +121,26 @@ public class MovementReplayer
*/
private static class ReplaySession
{
private final Function<NPC, NPC> npcFunction;
private final NPC npc;
private final MovementRecording recording;
private final double speedMultiplier;
private final boolean changeRealLocation;
private final Consumer<ReplayResult> onComplete;
private final long replayId;
private final long startTime;
private final Player[] viewers;
private NPC npc;
private BukkitTask replayTask;
private int currentIndex;
private long lastTimestamp;
private ReplaySession(@NotNull Function<NPC, NPC> npcFunction, @NotNull MovementRecording recording, double speedMultiplier,
private ReplaySession(@NotNull NPC npc, @NotNull MovementRecording recording, double speedMultiplier, boolean changeRealLocation,
@Nullable Consumer<ReplayResult> onComplete, long replayId, @NotNull Player... viewers)
{
this.npcFunction = npcFunction;
this.npc = npc;
this.recording = recording;
this.speedMultiplier = Math.max(0.1, speedMultiplier); // Minimum 0.1x speed
this.changeRealLocation = changeRealLocation;
this.onComplete = onComplete;
this.replayId = replayId;
this.startTime = System.currentTimeMillis();
@@ -169,13 +168,15 @@ public class MovementReplayer
}
Location startLocation = firstMovement.toLocation(world);
npc = npcFunction.apply(new NPC(startLocation));
npc.setEnabled(true);
for(Player p : viewers)
npc.showNPCToPlayer(p);
Location currentLocation = npc.getLocation();
npc.changeRealLocation(startLocation);
if(!changeRealLocation)
npc.setLocation(currentLocation);
activeReplays.put(npc.getUUID(), this);
@@ -237,7 +238,7 @@ public class MovementReplayer
TeleportEntityPacket teleport1 = new TeleportEntityPacket(npc.entity,
new TeleportEntityPacket.PositionMoveRotation(targetLocation.toVector(), new Vector(0, 0, 0), targetLocation.getYaw(),
targetLocation.getPitch()), Set.of(), true);
npc.sendNpcMovePackets(teleport1, head, npc.getViewers().stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toArray(Player[]::new));
npc.sendNpcMovePackets(teleport1, head, viewers);
}
else
{
@@ -245,10 +246,7 @@ public class MovementReplayer
new TeleportEntityPacket.PositionMoveRotation(previousMovement.toLocation(world).toVector(),
movement.toLocation(world).toVector(), targetLocation.getYaw(), targetLocation.getPitch()), Set.of(), true);
npc.sendNpcMovePackets(teleport, head, npc.getViewers().stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toArray(Player[]::new));
// Update NPC's internal location
npc.setLocation(targetLocation);
npc.sendNpcMovePackets(teleport, head, viewers);
}
}
else
@@ -257,9 +255,12 @@ public class MovementReplayer
TeleportEntityPacket teleport1 = new TeleportEntityPacket(npc.entity,
new TeleportEntityPacket.PositionMoveRotation(targetLocation.toVector(), new Vector(0, 0, 0), targetLocation.getYaw(),
targetLocation.getPitch()), Set.of(), true);
npc.sendNpcMovePackets(teleport1, head, npc.getViewers().stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toArray(Player[]::new));
npc.sendNpcMovePackets(teleport1, head, viewers);
}
if(changeRealLocation)
npc.setLocation(targetLocation);
lastTimestamp = movement.getTimestamp();
}
@@ -282,16 +283,7 @@ public class MovementReplayer
}
if(npc != null)
{
activeReplays.remove(npc.getUUID());
try
{
npc.delete();
}
catch(IOException e)
{
}
}
if(onComplete != null)
onComplete.accept(result);
+557 -34
View File
@@ -2,6 +2,7 @@ package de.eisi05.npc.api.objects;
import com.mojang.datafixers.util.Either;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.ai.GoalSelector;
import de.eisi05.npc.api.enums.WalkingResult;
import de.eisi05.npc.api.events.NpcHideEvent;
import de.eisi05.npc.api.events.NpcPostShowEvent;
@@ -9,6 +10,8 @@ import de.eisi05.npc.api.events.NpcPreShowEvent;
import de.eisi05.npc.api.events.NpcStartWalkingEvent;
import de.eisi05.npc.api.interfaces.NpcClickAction;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.manager.NpcVisibilityManager;
import de.eisi05.npc.api.pathfinding.PathfindingUtils;
import de.eisi05.npc.api.scheduler.PathTask;
import de.eisi05.npc.api.utils.ObjectSaver;
import de.eisi05.npc.api.utils.Var;
@@ -23,6 +26,7 @@ import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
@@ -40,6 +44,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -53,12 +59,14 @@ public class NPC extends NpcHolder
private final List<UUID> viewers = new ArrayList<>();
private final Path npcPath;
private final Map<UUID, PathTask> pathTasks = new HashMap<>();
private final Set<PathTask> walkingTasks = new LinkedHashSet<>();
public WrappedEntity<?> entity;
WrappedServerPlayer serverPlayer;
private Location location;
private NpcClickAction clickEvent;
private Instant createdAt = Instant.now();
private GoalSelector goalSelector;
/**
* Creates an NPC at the specified location with a random UUID and default name. The default name is an empty component.
@@ -111,6 +119,8 @@ public class NPC extends NpcHolder
setOption(value, Var.unsafeCast(value.getDefaultValue()));
NpcManager.addNPC(this);
startGoals();
serverPlayer.stopAdvancementListening();
}
/**
@@ -137,7 +147,16 @@ public class NPC extends NpcHolder
*/
public @NotNull NPC copy(@NotNull Location newLocation)
{
return new NPC(newLocation, name.copy(), new HashMap<>(options), clickEvent == null ? null : clickEvent.copy());
Map<UUID, Map<NpcOption<?, ?>, Object>> newOptions = new HashMap<>();
options.forEach((uuid, innerMap) ->
{
Map<NpcOption<?, ?>, Object> newInnerMap = new HashMap<>();
innerMap.forEach((npcOption, o) ->
newInnerMap.put(npcOption, npcOption.copy(Var.unsafeCast(o))));
newOptions.put(uuid, newInnerMap);
});
return new NPC(newLocation, name.copy(), newOptions, clickEvent == null ? null : clickEvent.copy());
}
/**
@@ -158,6 +177,9 @@ public class NPC extends NpcHolder
@Override
public void save() throws IOException
{
if(getUUID() == null)
return;
npcPath.toFile().getParentFile().mkdirs();
new ObjectSaver(npcPath.toFile()).write(SerializedNPC.serializedNPC(this), false);
super.save();
@@ -272,10 +294,26 @@ public class NPC extends NpcHolder
*/
public void reload()
{
boolean hasUnsavedChanges = hasUnsavedChanges();
final List<UUID> viewers = new ArrayList<>(this.viewers);
NpcVisibilityManager visibilityManager = getVisibilityManager();
boolean shouldShowToAll = visibilityManager.shouldShowToAllPlayers();
Set<UUID> specificPlayers = visibilityManager.getSpecificPlayers();
hideNpcFromAllPlayers();
WrappedPlayerTeam.clear(getServerPlayer().getName());
visibilityManager.setShowToAllPlayers(shouldShowToAll);
specificPlayers.forEach(visibilityManager::addSpecificPlayer);
viewers.stream().filter(uuid -> Bukkit.getPlayer(uuid) != null).forEach(uuid -> showNPCToPlayer(Bukkit.getPlayer(uuid)));
if(!hasUnsavedChanges)
{
try
{
super.save();
}
catch(IOException e)
{
}
}
}
/**
@@ -296,6 +334,7 @@ public class NPC extends NpcHolder
public void setLocation(@NotNull Location location)
{
this.location = location;
markChange();
if(serverPlayer == null)
return;
@@ -417,6 +456,16 @@ public class NPC extends NpcHolder
return getOption(NpcOption.CUSTOM_DATA, GLOBAL_UUID);
}
/**
* Gets the visibility manager for this NPC.
*
* @return the {@link NpcVisibilityManager} for this NPC. Will not be null.
*/
public @NotNull NpcVisibilityManager getVisibilityManager()
{
return getOption(NpcOption.VISIBILITY_MANAGER, GLOBAL_UUID);
}
/**
* Updates the display name of the given player on the server.
* <p>
@@ -430,7 +479,7 @@ public class NPC extends NpcHolder
.sendPacket(SetEntityDataPacket.create(serverPlayer.getNameTag().getId(), serverPlayer.getNameTag().applyData(
Versions.isCurrentVersionSmallerThan(Versions.V1_19_4) || isEnabled() ? name.getName(player) :
WrappedComponent.parseFromLegacy(NpcApi.DISABLED_MESSAGE_PROVIDER.apply(player))
.append(WrappedComponent.create("\n").append(name.getName(player))))));
.append(WrappedComponent.create("\n").append(name.getName(player))))));
}
/**
@@ -489,16 +538,23 @@ public class NPC extends NpcHolder
}
/**
* Makes the NPC visible to all currently online players. This respects the NPC's enabled state and player permissions.
* Makes the NPC visible to all currently online players and marks it to be shown to future players who join. This respects the NPC's enabled state and
* player permissions.
*/
public void showNpcToAllPlayers()
{
if(!getVisibilityManager().shouldShowToAllPlayers())
{
getVisibilityManager().setShowToAllPlayers(true);
markChange();
}
Bukkit.getOnlinePlayers().forEach(this::showNPCToPlayer);
}
/**
* Makes the NPC visible to a specific player. If the NPC is disabled and the player is not an operator, the NPC will not be shown. This method handles
* sending all necessary packets to display the NPC correctly.
* sending all necessary packets to display the NPC correctly. If the NPC is not marked to show to all players, the player will be added to the specific
* players list.
*
* @param player the player to show the NPC to. Must not be null.
*/
@@ -507,6 +563,12 @@ public class NPC extends NpcHolder
if(!getOption(NpcOption.ENABLED, GLOBAL_UUID) && !player.isPermissionSet("npc.admin") && !player.isOp())
return;
if(!getVisibilityManager().shouldShowToAllPlayers())
{
if(getVisibilityManager().addSpecificPlayer(player.getUniqueId()))
markChange();
}
if(!player.getWorld().getUID().equals(serverPlayer.getWorld().getUID()))
{
hideNpcFromPlayer(player);
@@ -535,7 +597,7 @@ public class NPC extends NpcHolder
Arrays.stream(NpcOption.values()).filter(NpcOption::loadBefore)
.forEach(npcOption -> npcOption.getPacket(getOption(npcOption, player), this, player).ifPresent(packets::add));
if(!name.isStatic() && getOption(NpcOption.SHOW_TAB_LIST))
if(!name.isStatic() && getOption(NpcOption.SHOW_TAB_LIST, player))
setOption(NpcOption.SHOW_TAB_LIST, false);
//packets.add(new PlayerInfoUpdatePacket(PlayerInfoUpdatePacket.Action.ADD_PLAYER, serverPlayer));
@@ -563,15 +625,24 @@ public class NPC extends NpcHolder
}
/**
* Hides the NPC from all currently online players.
* Hides the NPC from all currently online players and resets visibility settings. This will clear the "show to all players" flag and remove all specific
* players from the list.
*/
public void hideNpcFromAllPlayers()
{
if(getVisibilityManager().shouldShowToAllPlayers())
{
getVisibilityManager().setShowToAllPlayers(false);
getVisibilityManager().clearSpecificPlayers();
markChange();
}
Bukkit.getOnlinePlayers().forEach(this::hideNpcFromPlayer);
}
/**
* Hides the NPC from a specific player. This method sends packets to remove the NPC and its associated entities from the player's view.
* Hides the NPC from a specific player. This method sends packets to remove the NPC and its associated entities from the player's view. If the NPC is not
* set to show to all players, the player will also be removed from the specific players list.
*
* @param player the player to hide the NPC from. Must not be null.
*/
@@ -580,6 +651,12 @@ public class NPC extends NpcHolder
if(!viewers.contains(player.getUniqueId()))
return;
if(!getVisibilityManager().shouldShowToAllPlayers())
{
if(getVisibilityManager().removeSpecificPlayer(player.getUniqueId()))
markChange();
}
WrappedServerPlayer wrappedServerPlayer = WrappedServerPlayer.fromPlayer(player);
wrappedServerPlayer.sendPacket(new RemoveEntityPacket(serverPlayer.getId()));
wrappedServerPlayer.sendPacket(new RemoveEntityPacket(serverPlayer.getNameTag().getId()));
@@ -611,6 +688,7 @@ public class NPC extends NpcHolder
if(serverPlayer == null)
return;
stopGoals();
hideNpcFromAllPlayers();
NpcManager.removeNPC(this);
@@ -619,6 +697,22 @@ public class NPC extends NpcHolder
npcPath.toFile().getParentFile().mkdirs();
Files.deleteIfExists(npcPath);
super.save();
}
/**
* Returns the eye height of the NPC for a specific viewer.
*
* @param viewer The player to get the eye height for. If null, the default eye height is returned.
* @return The eye height of the NPC.
*/
public double getEyeHeight(@Nullable Player viewer)
{
org.bukkit.entity.Pose pose = viewer == null ? getOption(NpcOption.POSE) : getOption(NpcOption.POSE, viewer);
double scale = viewer == null ? getOption(NpcOption.SCALE) : getOption(NpcOption.SCALE, viewer);
double eyeHeight = (entity.getBukkitPlayer() instanceof LivingEntity le ? le.getEyeHeight() :
entity.getBukkitPlayer().getHeight()) - (Pose.fromBukkit(pose) == Pose.SITTING ? 0.625 : 0);
return eyeHeight * scale;
}
/**
@@ -658,7 +752,7 @@ public class NPC extends NpcHolder
if(entity == null)
return;
Location npcLoc = entity.getBukkitPlayer().getLocation();
Location npcLoc = location;
Location playerLoc = viewer.getLocation();
if(npcLoc.getWorld() != playerLoc.getWorld())
@@ -676,6 +770,9 @@ public class NPC extends NpcHolder
float yaw = (float) Math.toDegrees(Math.atan2(-dx, dz));
float pitch = (float) Math.toDegrees(-Math.atan2(dy, distanceXZ));
if(getOption(NpcOption.POSE, viewer) == org.bukkit.entity.Pose.SLEEPING)
yaw = 180.0F - yaw + 90.0F;
byte yawByte = (byte) (yaw * 256 / 360);
byte pitchByte = (byte) (pitch * 256 / 360);
@@ -685,6 +782,100 @@ public class NPC extends NpcHolder
player.sendPacket(new MoveEntityPacket.Rot(entity.getId(), yawByte, pitchByte, serverPlayer.isOnGround()));
}
/**
* Makes the NPC look at a specific player. This calculates the required yaw and pitch and sends update packets to the viewing player.
*
* @param targetEntity the entity the NPC should look at. Must not be null.
* @param viewer the player to send the packets to. Must not be null.
* @param force If true, the NPC's location will be updated; otherwise only packets are sent.
*/
public void lookAtEntity(@NotNull Entity targetEntity, @NotNull Player viewer, boolean force)
{
if(entity == null)
return;
Location npcLoc = this.location;
Location targetLoc = targetEntity.getLocation();
if(npcLoc.getWorld() != targetLoc.getWorld())
return;
double dx = targetLoc.getX() - npcLoc.getX();
double eyeHeight = (entity.getBukkitPlayer() instanceof LivingEntity le ? le.getEyeHeight() :
entity.getBukkitPlayer().getHeight()) - (Pose.fromBukkit(getOption(NpcOption.POSE, viewer)) == Pose.SITTING ? 0.625 : 0);
double dy = (targetLoc.getY() + (targetEntity instanceof LivingEntity le ? le.getEyeHeight() : targetEntity.getHeight())) -
(npcLoc.getY() + (eyeHeight * getOption(NpcOption.SCALE, viewer)));
double dz = targetLoc.getZ() - npcLoc.getZ();
double distanceXZ = Math.sqrt(dx * dx + dz * dz);
float yaw = (float) Math.toDegrees(Math.atan2(-dx, dz));
float pitch = (float) Math.toDegrees(-Math.atan2(dy, distanceXZ));
Location newLoc = new Location(npcLoc.getWorld(), npcLoc.getX(), npcLoc.getY(), npcLoc.getZ(), yaw, pitch);
if(force)
setLocation(newLoc);
byte yawByte = (byte) (yaw * 256 / 360);
byte pitchByte = (byte) (pitch * 256 / 360);
WrappedServerPlayer player = WrappedServerPlayer.fromPlayer(viewer);
player.sendPacket(new RotateHeadPacket(entity, yawByte));
player.sendPacket(new MoveEntityPacket.Rot(entity.getId(), yawByte, pitchByte, serverPlayer.isOnGround()));
}
/**
* Asynchronously calculates a path through the specified waypoints.
* <p>
* This method executes the pathfinding logic off the main thread using the Bukkit scheduler.
* </p>
*
* @param waypoints the list of locations the path must pass through, must not be null
* @param maxIterations the maximum number of iterations allowed for the pathfinding algorithm before giving up
* @param allowDiagonalMovement {@code true} to allow diagonal movement between nodes, {@code false} for straight lines only
* @param progressListener an optional listener to receive progress updates (current iteration, max iterations), can be null
* @return a {@link CompletableFuture} that will complete with the calculated {@link de.eisi05.npc.api.pathfinding.Path}
* @throws RuntimeException if an underlying {@link PathfindingUtils.PathfindingException} occurs during execution
*/
public @NotNull CompletableFuture<de.eisi05.npc.api.pathfinding.Path> findPathAsync(@NotNull List<Location> waypoints, int maxIterations,
boolean allowDiagonalMovement,
@Nullable BiConsumer<Integer, Integer> progressListener)
{
return CompletableFuture.supplyAsync(() ->
{
try
{
return findPath(waypoints, maxIterations, allowDiagonalMovement, progressListener);
}
catch(PathfindingUtils.PathfindingException e)
{
throw new RuntimeException(e);
}
}, runnable -> Bukkit.getScheduler().runTaskAsynchronously(NpcApi.plugin, runnable));
}
/**
* Synchronously calculates a path through the specified waypoints based on the entity's current bounding box and scale.
*
* @param waypoints the list of locations the path must pass through, must not be null
* @param maxIterations the maximum number of iterations allowed for the pathfinding algorithm before giving up
* @param allowDiagonalMovement {@code true} to allow diagonal movement between nodes, {@code false} for straight lines only
* @param progressListener an optional listener to receive progress updates (current iteration, max iterations), can be null
* @return the calculated {@link de.eisi05.npc.api.pathfinding.Path}, must not be null
* @throws PathfindingUtils.PathfindingException if the pathfinding algorithm fails to find a valid path or encounters an error
*/
public @NotNull de.eisi05.npc.api.pathfinding.Path findPath(@NotNull List<Location> waypoints, int maxIterations, boolean allowDiagonalMovement,
@Nullable BiConsumer<Integer, Integer> progressListener)
throws PathfindingUtils.PathfindingException
{
WrappedEntity.BoundingBox boundingBox = entity.getBoundingBox();
double scale = getOption(NpcOption.SCALE);
return PathfindingUtils.findPath(waypoints, maxIterations, allowDiagonalMovement, boundingBox.getYSize() * scale,
boundingBox.getXSize() * scale, progressListener);
}
/**
* Moves the NPC along a precomputed {@link de.eisi05.npc.api.pathfinding.Path}, simulating walking, jumping, and gravity. The NPC's position and rotation
* are updated each tick and sent to the specified player(s).
@@ -698,7 +889,7 @@ public class NPC extends NpcHolder
public @NotNull BukkitTask walkTo(@NotNull de.eisi05.npc.api.pathfinding.Path path, double walkSpeed, boolean changeRealLocation,
@Nullable Consumer<WalkingResult> onEnd)
{
return walkTo(path, walkSpeed, changeRealLocation, onEnd, null);
return walkTo(path, walkSpeed, changeRealLocation, onEnd, true, null);
}
/**
@@ -709,32 +900,74 @@ public class NPC extends NpcHolder
* @param walkSpeed The walking speed of the NPC (clamped between 0.1 and 1).
* @param changeRealLocation If true, the NPC's actual server-side location will be updated; otherwise only packets are sent.
* @param onEnd A {@link Runnable} to be executed when the NPC reaches the end of the path.
* @param withRotation If true, includes rotation packets in the movement; otherwise only position packets are sent.
* @return The {@link BukkitTask} representing the movement task.
*/
public @NotNull BukkitTask walkTo(@NotNull de.eisi05.npc.api.pathfinding.Path path, double walkSpeed, boolean changeRealLocation,
@Nullable Consumer<WalkingResult> onEnd, boolean withRotation)
{
return walkTo(path, walkSpeed, changeRealLocation, onEnd, withRotation, null);
}
/**
* Moves the NPC along a precomputed {@link de.eisi05.npc.api.pathfinding.Path}, simulating walking, jumping, and gravity. The NPC's position and rotation
* are updated each tick and sent to the specified player(s).
*
* @param path The {@link de.eisi05.npc.api.pathfinding.Path} containing the ordered waypoints the NPC should follow.
* @param walkSpeed The walking speed of the NPC (clamped between 0.1 and 1).
* @param changeRealLocation If true, the NPC's actual server-side location will be updated; otherwise only packets are sent.
* @param onEnd A {@link Runnable} to be executed when the NPC reaches the end of the path.
* @param withRotation If true, includes rotation packets in the movement; otherwise only position packets are sent.
* @param viewers The players who should see the NPC move. If null, updates all viewers in the `viewers` set.
* @return The {@link BukkitTask} representing the movement task.
*/
public @NotNull BukkitTask walkTo(@NotNull de.eisi05.npc.api.pathfinding.Path path, double walkSpeed,
boolean changeRealLocation, @Nullable Consumer<WalkingResult> onEnd, @Nullable List<Player> viewers)
boolean changeRealLocation, @Nullable Consumer<WalkingResult> onEnd,
boolean withRotation, @Nullable List<Player> viewers)
{
viewers = viewers == null || viewers.isEmpty() ? this.viewers.stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList() : viewers;
boolean explicitViewers = viewers != null && !viewers.isEmpty();
boolean autoManageWalkingViewers = !explicitViewers
&& NpcApi.config.autoManageVisibility()
&& NpcApi.config.autoManageWalkingViewers();
if(!explicitViewers)
{
if(autoManageWalkingViewers)
{
viewers = Bukkit.getOnlinePlayers().stream()
.filter(player -> canShowWalkingTo(player, getLocation()))
.collect(Collectors.toList());
}
else
{
viewers = this.viewers.stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
}
}
for(Player player : viewers)
{
if(isWalking(player))
cancelWalking(player);
}
final double speed = Math.max(Math.min(walkSpeed, 1), 0.1);
NpcStartWalkingEvent event = new NpcStartWalkingEvent(this, path, speed, changeRealLocation);
Bukkit.getPluginManager().callEvent(event);
if(event.isCancelled())
return null;
PathTask pathTask = new PathTask.Builder(this, path)
.speed(event.getWalkSpeed())
.viewers(viewers.toArray(new Player[0]))
.updateRealLocation(event.isChangeRealLocation())
.callback(onEnd).build();
.callback(onEnd)
.withRotation(withRotation)
.autoManageWalkingViewers(autoManageWalkingViewers)
.build();
walkingTasks.add(pathTask);
for(Player player : viewers)
pathTasks.put(player.getUniqueId(), pathTask);
@@ -743,31 +976,201 @@ public class NPC extends NpcHolder
}
/**
* Checks whether the NPC is currently walking.
* <p>
* The NPC is considered walking if a path task exists and has not yet finished.
* Checks whether this NPC has any active walking task.
*
* @param viewer Player who is viewing the NPC
* @return true if the NPC is still walking, false otherwise
* @return true if this NPC is walking; false otherwise
*/
public boolean isWalking(@NotNull Player viewer)
public boolean isWalking()
{
return pathTasks.containsKey(viewer.getUniqueId()) && !pathTasks.get(viewer.getUniqueId()).isFinished();
return walkingTasks.stream().anyMatch(task -> !task.isFinished());
}
/**
* Cancels the NPC's current walking task if one is active.
* <p>
* If the NPC is walking, the path task is canceled and cleared. If no walking task is active, this method has no effect.
* Checks whether this NPC is currently walking for the specified viewer.
*
* @param viewer Player who is viewing the NPC
* @param viewer the viewer to check
* @return true if this NPC has an active walking task for the viewer; false otherwise
*/
public boolean isWalking(@NotNull Player viewer)
{
PathTask task = pathTasks.get(viewer.getUniqueId());
return task != null && !task.isFinished();
}
/**
* Cancels this NPC's walking task for the specified viewer if one is active.
* <p>
* For automatically managed walking tasks, this only removes the viewer from the task and does not cancel the shared walking task for other viewers.
* <p>
* For manually created viewer-specific walking tasks, the task itself is cancelled.
*
* @param viewer the viewer whose walking task should be cancelled
*/
public void cancelWalking(@NotNull Player viewer)
{
if(!pathTasks.containsKey(viewer.getUniqueId()))
PathTask task = pathTasks.remove(viewer.getUniqueId());
if(task == null)
return;
pathTasks.remove(viewer.getUniqueId()).cancel();
task.removeViewer(viewer);
if(task.isAutoManageWalkingViewers())
return;
task.cancel();
}
/**
* Cancels all active walking tasks for this NPC.
* <p>
* If the NPC is currently walking, each unique path task is cancelled and cleared. If no walking task is active, this method has no effect.
*/
public void cancelWalking()
{
for(PathTask task : new HashSet<>(walkingTasks))
task.cancel();
walkingTasks.clear();
pathTasks.clear();
}
/**
* Checks whether a player is allowed to receive packets for this NPC's current walking position.
* <p>
* A player is eligible only if:
* <ul>
* <li>the player is online,</li>
* <li>the player is in the same world as the walking NPC,</li>
* <li>the player passes this NPC's visibility rules,</li>
* <li>the player is within {@link NpcConfig#walkingViewerDistance()} blocks, unless disabled.</li>
* </ul>
*
* @param player the player to check
* @param currentLocation the NPC's current walking location
* @return true if the player should receive walking packets; false otherwise
*/
public boolean canShowWalkingTo(@NotNull Player player, @NotNull Location currentLocation)
{
if(!player.isOnline())
return false;
if(currentLocation.getWorld() == null)
return false;
if(!player.getWorld().equals(currentLocation.getWorld()))
return false;
if(!getVisibilityManager().shouldShowToPlayer(player.getUniqueId()))
return false;
double distance = NpcApi.config.walkingViewerDistance();
if(distance >= 0 && player.getLocation().distanceSquared(currentLocation) > distance * distance)
return false;
return true;
}
/**
* Adds a player to this NPC's active automatically managed walking task, if possible.
* <p>
* This method does not start or cancel any walking task. It only attaches the player to an already running task that allows automatic viewer management.
*
* @param player the player to add as a walking viewer
*/
public void addWalkingViewer(@NotNull Player player)
{
if(!NpcApi.config.autoManageVisibility() || !NpcApi.config.autoManageWalkingViewers())
return;
for(PathTask task : new ArrayList<>(walkingTasks))
{
if(task.isFinished())
{
clearWalkingTask(task);
continue;
}
if(!task.isAutoManageWalkingViewers())
continue;
if(!canShowWalkingTo(player, task.getCurrentLocation()))
continue;
PathTask currentTask = pathTasks.get(player.getUniqueId());
if(currentTask == task && task.hasViewer(player))
return;
if(currentTask != null && currentTask != task)
removeWalkingViewer(player);
boolean newlyAdded = !task.hasViewer(player);
pathTasks.put(player.getUniqueId(), task);
if(newlyAdded)
showNPCToPlayer(player);
task.addViewer(player);
return;
}
}
/**
* Removes a player from all active walking tasks for this NPC.
* <p>
* This does not cancel the walking task itself. It only stops sending movement packets to the specified player.
*
* @param player the player to remove
*/
public void removeWalkingViewer(@NotNull Player player)
{
PathTask task = pathTasks.remove(player.getUniqueId());
if(task != null)
task.removeViewer(player);
}
/**
* Removes all references to a finished or cancelled walking task.
*
* @param task the task to clear
*/
public void clearWalkingTask(@NotNull PathTask task)
{
walkingTasks.remove(task);
pathTasks.entrySet().removeIf(entry -> entry.getValue() == task);
}
/**
* Re-checks all online players for active automatically managed walking tasks.
* <p>
* Eligible players are added to the walking task. Players who are no longer eligible, for example because they changed worlds or moved too far away, are
* removed.
*/
public void refreshWalkingViewers()
{
if(!NpcApi.config.autoManageVisibility() || !NpcApi.config.autoManageWalkingViewers())
return;
for(PathTask task : new ArrayList<>(walkingTasks))
{
if(task.isFinished())
{
clearWalkingTask(task);
continue;
}
if(!task.isAutoManageWalkingViewers())
continue;
for(Player player : Bukkit.getOnlinePlayers())
{
if(canShowWalkingTo(player, task.getCurrentLocation()))
addWalkingViewer(player);
else if(pathTasks.get(player.getUniqueId()) == task)
removeWalkingViewer(player);
}
}
}
/**
@@ -851,12 +1254,52 @@ public class NPC extends NpcHolder
}
}
/**
* Gets the goal selector for this NPC. Creates a new one if it doesn't exist.
*
* @return The goal selector for this NPC
*/
@Override
protected @NotNull GoalSelector getGoalSelector()
{
if(goalSelector == null)
goalSelector = new GoalSelector(this);
return goalSelector;
}
/**
* Starts the goal system for this NPC. The goal selector will begin evaluating and executing goals.
*/
public void startGoals()
{
if(!isGoalSystemRunning())
getGoalSelector().start();
}
/**
* Stops the goal system for this NPC. The goal selector will stop evaluating and executing goals.
*/
public void stopGoals()
{
getGoalSelector().stop();
}
/**
* Checks whether the goal system is currently running for this NPC.
*
* @return true if the goal system is running, false otherwise
*/
public boolean isGoalSystemRunning()
{
return goalSelector != null && goalSelector.isRunning();
}
/**
* Updates the real location of the NPC and refreshes visibility for viewers. * @param location The new target location.
*
* @param excludedPlayers Players who should NOT see the respawn/refresh.
*/
public void changeRealLocation(Location location, @Nullable Player... excludedPlayers)
public void changeRealLocation(@NotNull Location location, @Nullable Player... excludedPlayers)
{
if(serverPlayer == null)
return;
@@ -866,13 +1309,35 @@ public class NPC extends NpcHolder
Set<UUID> excluded = excludedPlayers == null ? Collections.emptySet() :
Arrays.stream(excludedPlayers).filter(Objects::nonNull).map(Player::getUniqueId).collect(Collectors.toSet());
TeleportEntityPacket teleport1 = new TeleportEntityPacket(serverPlayer,
new TeleportEntityPacket.PositionMoveRotation(location.toVector(), new Vector(0, 0, 0), location.getYaw(), location.getPitch()),
Set.of(), true);
float baseYaw = location.getYaw() + 360F;
float pitch = location.getPitch();
float renderYaw = baseYaw;
if(getOption(NpcOption.POSE) == org.bukkit.entity.Pose.SLEEPING)
{
renderYaw = 180.0F - baseYaw + 90.0F;
pitch = 0F;
}
PacketWrapper teleport1 = new TeleportEntityPacket(serverPlayer, new TeleportEntityPacket.PositionMoveRotation(location.toVector(), new Vector(0, 0, 0),
renderYaw, pitch), Set.of(), true);
byte renderYawByte = (byte) (renderYaw * 256 / 360);
PacketWrapper rotPacket;
if(getOption(NpcOption.POSE) == org.bukkit.entity.Pose.SLEEPING)
rotPacket = new BundlePacket(
new MoveEntityPacket.Rot(entity.getId(), (byte) (renderYawByte - 35), (byte) (pitch * 256 / 360), entity.getBukkitPlayer().isOnGround()),
new RotateHeadPacket(entity, renderYawByte));
else
{
byte yawByte = (byte) (baseYaw * 256 / 360);
rotPacket = new BundlePacket(new MoveEntityPacket.Rot(entity.getId(), (byte) (yawByte - 35), (byte) (pitch * 256 / 360),
entity.getBukkitPlayer().isOnGround()), new RotateHeadPacket(entity, yawByte));
}
TeleportEntityPacket teleport2 = entity.equals(serverPlayer) ? null : new TeleportEntityPacket(entity,
new TeleportEntityPacket.PositionMoveRotation(location.toVector(), new Vector(0, 0, 0), location.getYaw(), location.getPitch()),
Set.of(), true);
new TeleportEntityPacket.PositionMoveRotation(location.toVector(), new Vector(0, 0, 0), baseYaw, pitch), Set.of(), true);
for(UUID uuid : viewers)
{
@@ -888,9 +1353,67 @@ public class NPC extends NpcHolder
if(teleport2 != null)
serverPlayer1.sendPacket(teleport2);
if(rotPacket != null)
serverPlayer1.sendPacket(rotPacket);
}
}
/**
* Updates this NPC's location and rotation for a specific player by sending the appropriate teleport and rotation packets.
* <p>
* Sleeping NPCs require special handling because the client renders their orientation differently from standing entities. In that case, a modified yaw is
* used and the pitch is forced to {@code 0}.
*
* @param location the location to display the NPC at
* @param player the player to send the location update to
*/
public void updateLocationForPlayer(@NotNull Location location, @NotNull Player player)
{
if(serverPlayer == null)
return;
float baseYaw = location.getYaw() + 360F;
float pitch = location.getPitch();
float renderYaw = baseYaw;
if(getOption(NpcOption.POSE, player) == org.bukkit.entity.Pose.SLEEPING)
{
renderYaw = 180.0F - baseYaw + 90.0F;
pitch = 0F;
}
PacketWrapper teleport1 = new TeleportEntityPacket(serverPlayer, new TeleportEntityPacket.PositionMoveRotation(location.toVector(), new Vector(0, 0, 0),
renderYaw, pitch), Set.of(), true);
byte renderYawByte = (byte) (renderYaw * 256 / 360);
PacketWrapper rotPacket;
if(getOption(NpcOption.POSE, player) == org.bukkit.entity.Pose.SLEEPING)
rotPacket = new BundlePacket(
new MoveEntityPacket.Rot(entity.getId(), (byte) (renderYawByte - 35), (byte) (pitch * 256 / 360),
entity.getBukkitPlayer().isOnGround()),
new RotateHeadPacket(entity, renderYawByte));
else
{
byte yawByte = (byte) (baseYaw * 256 / 360);
rotPacket = new BundlePacket(new MoveEntityPacket.Rot(entity.getId(), (byte) (yawByte - 35), (byte) (pitch * 256 / 360),
entity.getBukkitPlayer().isOnGround()), new RotateHeadPacket(entity, yawByte));
}
TeleportEntityPacket teleport2 = entity.equals(serverPlayer) ? null : new TeleportEntityPacket(entity,
new TeleportEntityPacket.PositionMoveRotation(location.toVector(), new Vector(0, 0, 0), baseYaw, pitch), Set.of(), true);
WrappedServerPlayer serverPlayer1 = WrappedServerPlayer.fromPlayer(player);
serverPlayer1.sendPacket(teleport1);
if(teleport2 != null)
serverPlayer1.sendPacket(teleport2);
if(rotPacket != null)
serverPlayer1.sendPacket(rotPacket);
}
/**
* A serializable representation of an NPC that can be stored and later restored. This class handles the conversion between in-memory {@link NPC} objects
* and their persistent storage format.
@@ -1,5 +1,6 @@
package de.eisi05.npc.api.objects;
import de.eisi05.npc.api.utils.ApiOnly;
import org.jetbrains.annotations.NotNull;
/**
@@ -47,6 +48,54 @@ public class NpcConfig
*/
private boolean checkValidPath = true;
/**
* If true, the API will automatically manage NPC visibility for players across various events. This includes showing NPCs when players join, change worlds,
* or load chunks, and hiding NPCs when players quit.
*/
@ApiOnly
private boolean autoManageVisibility = true;
/**
* If true, walking NPCs automatically manage viewers while a path task is active.
* <p>
* When enabled, players who join, change worlds, respawn, or load nearby chunks can be added to the active walking task and synced to the NPC's current
* walking position.
* <p>
* This only applies to walking tasks created without an explicit viewer list.
* <p>
* Default: true
*/
@ApiOnly
private boolean autoManageWalkingViewers = true;
/**
* Maximum distance in blocks at which walking NPC movement is shown to players.
* <p>
* Players farther than this distance from the NPC's current walking position will not receive walking movement packets. Players in another world are never
* shown walking movement.
* <p>
* Set to {@code -1} to disable the distance check.
* <p>
* Default: 64 blocks.
*/
private double walkingViewerDistance = 64.0;
/**
* If true, precise hitbox detection is used for sleeping NPCs.
* <p>
* When enabled, the {@link de.eisi05.npc.api.listeners.NpcSleepListener} will use accurate ray intersection calculations to detect interactions with
* sleeping NPCs. This provides more precise click detection but may be more computationally expensive.
* <p>
* Default: false
*/
private boolean preciseSleepingHitbox = false;
/**
* If true, the NPC will asynchronously load unloaded chunks that it is walking into.
* Default: false
*/
private boolean loadChunksOnPath = false;
/**
* Sets the duration an NPC will look at a player after an interaction.
*
@@ -132,6 +181,76 @@ public class NpcConfig
return this;
}
/**
* Sets whether the API should automatically manage NPC visibility for players.
*
* @param autoManageVisibility True to enable automatic visibility management, false to disable it.
* @return This {@link NpcConfig} instance for method chaining. Never null.
*/
public @NotNull NpcConfig autoManageVisibility(boolean autoManageVisibility)
{
this.autoManageVisibility = autoManageVisibility;
return this;
}
/**
* Sets whether walking NPCs should automatically manage viewers during active path tasks.
* <p>
* This only applies when {@link #autoManageVisibility()} is enabled and the walking task was created without an explicit viewer list.
*
* @param autoManageWalkingViewers true to automatically manage walking viewers, false otherwise
* @return This {@link NpcConfig} instance for method chaining. Never null.
*/
public @NotNull NpcConfig autoManageWalkingViewers(boolean autoManageWalkingViewers)
{
this.autoManageWalkingViewers = autoManageWalkingViewers;
return this;
}
/**
* Sets the maximum distance in blocks at which walking NPC movement is shown to players.
* <p>
* Players farther than this distance from the NPC's current walking position will not receive walking packets. Players in another world are never shown
* walking movement.
* <p>
* Use {@code -1} to disable the distance check.
*
* @param walkingViewerDistance the maximum distance in blocks, or -1 to disable
* @return This {@link NpcConfig} instance for method chaining. Never null.
*/
public @NotNull NpcConfig walkingViewerDistance(double walkingViewerDistance)
{
this.walkingViewerDistance = walkingViewerDistance;
return this;
}
/**
* Sets whether precise hitbox detection is used for sleeping NPCs.
* <p>
* When enabled, the {@link de.eisi05.npc.api.listeners.NpcSleepListener} will use accurate ray intersection calculations to detect interactions with
* sleeping NPCs.
*
* @param preciseSleepingHitbox true to enable precise hitbox detection, false otherwise
* @return This {@link NpcConfig} instance for method chaining. Never null.
*/
public @NotNull NpcConfig preciseSleepingHitbox(boolean preciseSleepingHitbox)
{
this.preciseSleepingHitbox = preciseSleepingHitbox;
return this;
}
/**
* Checks whether NPCs should load unloaded chunks on their path.
*
* @param loadChunksOnPath true to enable chunk loading, false otherwise
* @return This {@link NpcConfig} instance for method chaining. Never null.
*/
public @NotNull NpcConfig loadChunksOnPath(boolean loadChunksOnPath)
{
this.loadChunksOnPath = loadChunksOnPath;
return this;
}
/**
* Gets the configured duration an NPC will look at a player.
*
@@ -201,4 +320,54 @@ public class NpcConfig
{
return checkValidPath;
}
/**
* Checks whether the API automatically manages NPC visibility for players.
*
* @return True if automatic visibility management is enabled; false otherwise.
*/
public boolean autoManageVisibility()
{
return autoManageVisibility;
}
/**
* Checks whether walking NPCs automatically manage viewers during active path tasks.
*
* @return true if automatic walking viewer management is enabled; false otherwise
*/
public boolean autoManageWalkingViewers()
{
return autoManageWalkingViewers;
}
/**
* Gets the maximum distance in blocks at which walking NPC movement is shown to players.
*
* @return the maximum walking viewer distance in blocks, or -1 if disabled
*/
public double walkingViewerDistance()
{
return walkingViewerDistance;
}
/**
* Checks whether precise hitbox detection is enabled for sleeping NPCs.
*
* @return true if precise hitbox detection is enabled, false otherwise
*/
public boolean preciseSleepingHitbox()
{
return preciseSleepingHitbox;
}
/**
* Checks whether NPCs should load unloaded chunks on their path.
*
* @return true if chunks should be loaded, false otherwise
*/
public boolean loadChunksOnPath()
{
return loadChunksOnPath;
}
}
@@ -1,6 +1,8 @@
package de.eisi05.npc.api.objects;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.ai.GoalSelector;
import de.eisi05.npc.api.wrapper.objects.WrappedComponent;
import de.eisi05.npc.api.wrapper.objects.WrappedServerPlayer;
import org.bukkit.Bukkit;
@@ -13,10 +15,7 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.*;
/**
* Abstract class representing an entity that can hold NPC-related data and has a concept of unsaved changes. It implements {@link InventoryHolder} but onlay as
@@ -26,6 +25,7 @@ public abstract class NpcHolder implements InventoryHolder
{
protected static final UUID GLOBAL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
protected final Map<UUID, Map<NpcOption<?, ?>, Object>> options = new HashMap<>();
private final Map<UUID, NamespacedKey> playerKeyCache = new HashMap<>();
protected NpcName name;
@@ -85,6 +85,13 @@ public abstract class NpcHolder implements InventoryHolder
*/
public abstract void setName(@NotNull NpcName name);
/**
* Gets the goal selector for this NPC holder.
*
* @return The goal selector for this NPC holder, or {@code null}
*/
protected abstract @Nullable GoalSelector getGoalSelector();
/**
* Sets an option value for this NPC holder with the global UUID.
*
@@ -149,6 +156,20 @@ public abstract class NpcHolder implements InventoryHolder
}
}
/**
* Sets an option value for this NPC holder for a specific player.
*
* @param <T> the type of the option value
* @param option the option to set
* @param value the value to set for the option, or {@code null} to remove the option
* @param player the player to associate with this option setting
*/
<T> void setOption(@NotNull NpcOption<T, ?> option, @Nullable T value, @NotNull Player player)
{
player.getPersistentDataContainer().set(getKey(player), PersistentDataType.STRING, player.getUniqueId().toString());
setOption(option, value, player.getUniqueId());
}
/**
* Gets the value of an option for a specific UUID.
*
@@ -166,6 +187,10 @@ public abstract class NpcHolder implements InventoryHolder
if(playerOptions != null && playerOptions.containsKey(option))
return (T) playerOptions.get(option);
Map<NpcOption<?, ?>, Object> globalOptions = options.get(GLOBAL_UUID);
if(globalOptions != null && globalOptions.containsKey(option))
return (T) globalOptions.get(option);
return option.getDefaultValue();
}
@@ -211,7 +236,8 @@ public abstract class NpcHolder implements InventoryHolder
*/
protected @NotNull NamespacedKey getKey(@NotNull Player player)
{
return new NamespacedKey(NpcApi.plugin, getUUID() + "-" + player.getUniqueId());
return playerKeyCache.computeIfAbsent(player.getUniqueId(),
uuid -> new NamespacedKey(NpcApi.plugin, getUUID() + "-" + uuid));
}
/**
@@ -272,6 +298,56 @@ public abstract class NpcHolder implements InventoryHolder
return options;
}
/**
* Adds a goal to this NPC's goal selector.
*
* @param goal The goal to add
*/
public void addGoal(@NotNull Goal goal)
{
ArrayList<Goal> goals = getOption(NpcOption.GOALS);
goals.add(goal);
setOption(NpcOption.GOALS, goals);
}
/**
* Removes a goal from this NPC. If the goal is currently running, it will be queued for removal and removed once it finishes naturally. If the goal is not
* currently running, it will be immediately removed from the NPC's goal list.
*
* @param goal The goal to remove
* @return true if the goal was removed or queued for removal, false if the goal was not found
*/
public boolean removeGoal(@NotNull Goal goal)
{
GoalSelector selector = getGoalSelector();
if(selector != null && !selector.queueGoalForRemoval(goal))
return true;
ArrayList<Goal> goals = getOption(NpcOption.GOALS);
boolean removed = goals.remove(goal);
setOption(NpcOption.GOALS, goals);
return removed;
}
/**
* Gets the list of goals associated with this NPC.
* <p>
* This method returns all active goals for this NPC, excluding any goals that are currently queued for removal. Goals that are in the removal queue will
* not appear in the returned list even if they are still running.
*
* @return A list of active goals, or an empty list if no goals are set
*/
public @NotNull List<Goal> getGoals()
{
return getOption(NpcOption.GOALS).stream().filter(goal ->
{
GoalSelector goalSelector = getGoalSelector();
if(goalSelector == null)
return true;
return !goalSelector.isGoalQueuedForRemoval(goal);
}).toList();
}
/**
* @throws UnsupportedOperationException always, as this inventory is not used.
*/
@@ -6,15 +6,18 @@ import com.mojang.authlib.properties.Property;
import com.mojang.authlib.properties.PropertyMap;
import com.mojang.datafixers.util.Pair;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.ai.Goal;
import de.eisi05.npc.api.enums.NpcVisibility;
import de.eisi05.npc.api.enums.SkinParts;
import de.eisi05.npc.api.manager.NpcManager;
import de.eisi05.npc.api.manager.NpcVisibilityManager;
import de.eisi05.npc.api.scheduler.Tasks;
import de.eisi05.npc.api.utils.*;
import de.eisi05.npc.api.wrapper.enums.ChatFormat;
import de.eisi05.npc.api.wrapper.objects.*;
import de.eisi05.npc.api.wrapper.packets.*;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.entity.Pose;
@@ -28,7 +31,7 @@ import java.io.Serializable;
import java.lang.reflect.Field;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.function.Supplier;
/**
* Represents a configurable option for an NPC. Each option has a path, a default value, serialization/deserialization logic, and a function to generate a
@@ -43,8 +46,8 @@ public class NpcOption<T, S extends Serializable>
* NPC option to determine if the NPC should use the skin of the viewing player. If true, the NPC's skin will be dynamically set to the skin of the player
* looking at it.
*/
public static final NpcOption<Boolean, Boolean> USE_PLAYER_SKIN = new NpcOption<>("use-player-skin", false,
aBoolean -> aBoolean, aBoolean -> aBoolean,
public static final NpcOption<Boolean, Boolean> USE_PLAYER_SKIN = new NpcOption<>("use-player-skin", () -> false,
aBoolean -> aBoolean, aBoolean -> aBoolean, aBoolean -> aBoolean,
(skin, npc, player) ->
{
if(!skin || !npc.serverPlayer.equals(npc.entity))
@@ -94,8 +97,8 @@ public class NpcOption<T, S extends Serializable>
/**
* NPC option to set a specific skin using a value and signature. This is ignored if {@link #USE_PLAYER_SKIN} is true.
*/
public static final NpcOption<NpcSkin, SkinData> SKIN = new NpcOption<NpcSkin, SkinData>("skin", null,
skin -> skin, skin -> skin instanceof Skin skin1 ? NpcSkin.of(skin1) : (NpcSkin) skin,
public static final NpcOption<NpcSkin, SkinData> SKIN = new NpcOption<NpcSkin, SkinData>("skin", () -> null,
skin -> skin, skin -> skin, skin -> skin instanceof Skin skin1 ? NpcSkin.of(skin1) : (NpcSkin) skin,
(skinData, npc, player) ->
{
if(npc.getOption(USE_PLAYER_SKIN, player) || skinData == null || !npc.serverPlayer.equals(npc.entity))
@@ -140,8 +143,8 @@ public class NpcOption<T, S extends Serializable>
* NPC option to control whether the NPC is shown in the player tab list. If false, the NPC will be removed from the tab list for the viewing player after a
* short delay.
*/
public static final NpcOption<Boolean, Boolean> SHOW_TAB_LIST = new NpcOption<>("show-tab-list", true,
aBoolean -> aBoolean, aBoolean -> aBoolean,
public static final NpcOption<Boolean, Boolean> SHOW_TAB_LIST = new NpcOption<>("show-tab-list", () -> true,
aBoolean -> aBoolean, aBoolean -> aBoolean, aBoolean -> aBoolean,
(show, npc, player) ->
{
if(!show || !npc.name.isStatic())
@@ -165,8 +168,8 @@ public class NpcOption<T, S extends Serializable>
/**
* NPC option to set the simulated latency (ping) of the NPC in the tab list.
*/
public static final NpcOption<Integer, Integer> LATENCY = new NpcOption<>("latency", 0,
aInteger -> aInteger, aInteger -> aInteger,
public static final NpcOption<Integer, Integer> LATENCY = new NpcOption<>("latency", () -> 0,
aInteger -> aInteger, aInteger -> aInteger, aInteger -> aInteger,
(latency, npc, player) ->
{
npc.getServerPlayer().setLatency(latency);
@@ -176,8 +179,8 @@ public class NpcOption<T, S extends Serializable>
/**
* NPC option to control the visibility of the NPC's nametag.
*/
public static final NpcOption<Boolean, Boolean> HIDE_NAMETAG = new NpcOption<>("hide-nametag", false,
aBoolean -> aBoolean, aBoolean -> aBoolean,
public static final NpcOption<Boolean, Boolean> HIDE_NAMETAG = new NpcOption<>("hide-nametag", () -> false,
aBoolean -> aBoolean, aBoolean -> aBoolean, aBoolean -> aBoolean,
(hide, npc, player) ->
{
WrappedEntity<?> nameTag = npc.getServerPlayer().getNameTag();
@@ -200,13 +203,39 @@ public class NpcOption<T, S extends Serializable>
* NPC option to set the equipment worn by the NPC (armor, items in hand). The map uses {@link EquipmentSlot} as keys and {@link ItemStack} as values.
* Serialized form uses item base64 strings.
*/
public static final NpcOption<Map<EquipmentSlot, ItemStack>, HashMap<EquipmentSlot, String>> EQUIPMENT = new NpcOption<>("equipment", Map.of(),
map -> map.entrySet().stream().collect(
Collectors.toMap(Map.Entry::getKey,
entry -> ItemSerializer.itemStackToBase64(entry.getValue()), (a, b) -> b, HashMap::new)),
serializedMap -> serializedMap.entrySet().stream().collect(
Collectors.toMap(Map.Entry::getKey,
entry -> ItemSerializer.itemStackFromBase64(entry.getValue()), (a, b) -> b, HashMap::new)),
public static final NpcOption<Map<EquipmentSlot, ItemStack>, HashMap<EquipmentSlot, String>> EQUIPMENT = new NpcOption<>("equipment", HashMap::new, HashMap::new,
map ->
{
HashMap<EquipmentSlot, String> serializedMap = new HashMap<>();
map.forEach((slot, item) ->
{
if(item == null || item.getType().isAir())
return;
String serialized = ItemSerializer.itemStackToBase64(item);
if(serialized == null)
return;
serializedMap.put(slot, serialized);
});
return serializedMap;
},
serializedMap ->
{
HashMap<EquipmentSlot, ItemStack> map = new HashMap<>();
serializedMap.forEach((slot, string) ->
{
if(string == null || string.isEmpty())
return;
ItemStack item = ItemSerializer.itemStackFromBase64(string);
if(item == null)
return;
map.put(slot, item);
});
return map;
},
(map, npc, player) ->
{
if(map.isEmpty())
@@ -223,8 +252,8 @@ public class NpcOption<T, S extends Serializable>
/**
* NPC option to control which parts of the NPC's skin are visible (e.g., hat, jacket). For a full list look at {@link SkinParts}.
*/
public static final NpcOption<SkinParts[], SkinParts[]> SKIN_PARTS = new NpcOption<>("skin-parts", SkinParts.values(),
skinParts -> skinParts, skinParts -> skinParts,
public static final NpcOption<SkinParts[], SkinParts[]> SKIN_PARTS = new NpcOption<>("skin-parts", SkinParts::values,
skinParts -> skinParts, skinParts -> skinParts, skinParts -> skinParts,
(skinParts, npc, player) ->
{
WrappedEntityData data = npc.getServerPlayer().getEntityData();
@@ -237,91 +266,10 @@ public class NpcOption<T, S extends Serializable>
* NPC option to make the NPC look at the player if they are within a certain distance. The value is the maximum distance in blocks. A value of 0 or less
* disables this. The actual looking logic is handled by {@link Tasks#lookAtTask()}.
*/
public static final NpcOption<Double, Double> LOOK_AT_PLAYER = new NpcOption<>("look-at-player", 0.0,
distance -> distance, distance -> distance,
public static final NpcOption<Double, Double> LOOK_AT_PLAYER = new NpcOption<>("look-at-player", () -> 0.0,
distance -> distance, distance -> distance, distance -> distance,
(distance, npc, player) -> null);
/**
* NPC option to set the pose of the NPC (e.g., standing, sleeping, swimming). For a full list look at {@link Pose}.
*/
public static final NpcOption<Pose, Pose> POSE = new NpcOption<>("pose", Pose.STANDING,
pose -> pose, pose -> pose,
(pose, npc, player) ->
{
de.eisi05.npc.api.wrapper.enums.Pose nmsPose = de.eisi05.npc.api.wrapper.enums.Pose.fromBukkit(pose);
if(nmsPose == null)
throw new RuntimeException("Pose (" + pose.name() + ") not found");
npc.getServerPlayer().setPose(nmsPose);
WrappedEntityData data = npc.entity.getEntityData();
data.set(WrappedEntityData.EntityDataSerializers.ENTITY_POSE.create(6), nmsPose);
WrappedEntityData.EntityDataAccessor<Byte> accessor = WrappedEntityData.EntityDataSerializers.BYTE.create(0);
Byte value = data.get(accessor);
byte flags = value == null ? 0 : value;
PacketWrapper packetWrapper = null;
if(pose == Pose.FALL_FLYING)
{
data.set(accessor, (byte) (flags | 0x80));
packetWrapper = new MoveEntityPacket.Rot(npc.entity.getId(), (byte) (npc.getLocation().getYaw() * 256 / 360),
(byte) 0, npc.getServerPlayer().isOnGround());
}
else if(pose == Pose.SWIMMING)
data.set(accessor, (byte) (flags | 0x10));
else
data.set(accessor, (byte) (flags & ~(0x80 | 0x10)));
if(!npc.entity.getBukkitPlayer().getType().name().equals("ITEM_DISPLAY") &&
!npc.entity.getBukkitPlayer().getType().name().equals("BLOCK_DISPLAY"))
{
if(pose == Pose.SPIN_ATTACK)
{
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(8), (byte) 0x04);
packetWrapper = new MoveEntityPacket.Rot(npc.entity.getId(), (byte) (npc.getLocation().getYaw() * 256 / 360),
(byte) -90, npc.getServerPlayer().isOnGround());
}
else
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(8), (byte) 0x01);
}
if(nmsPose == de.eisi05.npc.api.wrapper.enums.Pose.SITTING)
{
WrappedTextDisplay textDisplay = WrappedTextDisplay.create(npc.entity.getBukkitPlayer().getLocation().getWorld());
textDisplay.moveTo(npc.entity.getBukkitPlayer().getLocation());
npc.toDeleteEntities.put("sit", textDisplay.getId());
PacketWrapper addEntityPacket = textDisplay.getAddEntityPacket();
WrappedEntityData wrappedEntityData = textDisplay.getEntityData();
wrappedEntityData.set(accessor, (byte) (flags | 0x20));
SetEntityDataPacket entityDataPacket = SetEntityDataPacket.create(textDisplay.getId(), wrappedEntityData);
textDisplay.setPassengers(npc.entity);
SetPassengerPacket passengerPacket = new SetPassengerPacket(textDisplay);
RotateHeadPacket rotateHeadPacket = new RotateHeadPacket(npc.entity,
(byte) (npc.getLocation().getYaw() * 256 / 360));
return new BundlePacket(addEntityPacket, entityDataPacket, passengerPacket, rotateHeadPacket);
}
else
{
Integer toDelete = npc.toDeleteEntities.get("sit");
if(toDelete == null)
return packetWrapper == null ? SetEntityDataPacket.create(npc.entity.getId(), data) :
new BundlePacket(SetEntityDataPacket.create(npc.entity.getId(), data), packetWrapper);
npc.toDeleteEntities.remove("sit");
return packetWrapper == null ?
new BundlePacket(new RemoveEntityPacket(toDelete), SetEntityDataPacket.create(npc.entity.getId(), data)) :
new BundlePacket(new RemoveEntityPacket(toDelete), SetEntityDataPacket.create(npc.entity.getId(), data), packetWrapper);
}
});
/**
* NPC option to control visibility with three states: fully visible, transparent, or invisible.
* <p>
@@ -332,16 +280,17 @@ public class NpcOption<T, S extends Serializable>
* </ul>
* <p>
*/
public static final NpcOption<NpcVisibility, NpcVisibility> VISIBILITY = new NpcOption<>("visibility", NpcVisibility.FULLY_VISIBLE,
visibility -> visibility, visibility -> visibility, (visibility, npc, player) ->
public static final NpcOption<NpcVisibility, NpcVisibility> VISIBILITY = new NpcOption<>("visibility", () -> NpcVisibility.FULLY_VISIBLE,
visibility -> visibility, visibility -> visibility, visibility -> visibility, (visibility, npc, player) ->
{
String teamName = "trans-" + player.getEntityId();
WrappedPlayerTeam playerTeam = WrappedPlayerTeam.getPlayersTeam(player);
boolean modified = WrappedPlayerTeam.exists(player, teamName) || playerTeam.getHandle() != null;
WrappedPlayerTeam wrappedPlayerTeam = playerTeam.getHandle() != null ? playerTeam : WrappedPlayerTeam.create(player, teamName);
wrappedPlayerTeam.setCanSeeFriendlyInvisible(true);
wrappedPlayerTeam.setNameTagVisibility(WrappedPlayerTeam.Visibility.HIDE_FOR_OWN_TEAM);
if(!wrappedPlayerTeam.canSeeFriendlyInvisible())
wrappedPlayerTeam.setCanSeeFriendlyInvisible(true);
var teamPacket = SetPlayerTeamPacket.createAddOrModifyPacket(wrappedPlayerTeam, !modified);
@@ -381,23 +330,6 @@ public class NpcOption<T, S extends Serializable>
return new BundlePacket(teamPacket, SetEntityDataPacket.create(npc.entity.getId(), entityData), addNpc, addPlayer);
});
/**
* NPC option to set the scale (size) of the NPC. A value of 1.0 is normal size. Requires Minecraft 1.20.6 or newer.
*/
public static final NpcOption<Double, Double> SCALE = new NpcOption<>("scale", 1.0,
scale -> scale, scale -> scale,
(scale, npc, player) ->
{
if(npc.entity.getBukkitPlayer().getType().name().equals("ITEM_DISPLAY") ||
npc.entity.getBukkitPlayer().getType().name().equals("BLOCK_DISPLAY"))
return null;
WrappedAttributeInstance instance = npc.getServerPlayer().getAttribute(WrappedAttributeInstance.Attributes.SCALE_HOLDER);
instance.setBaseValue(scale);
return new UpdateAttributesPacket(npc.entity.getId(), instance);
}).since(Versions.V1_20_6);
/**
* NPC option to make the NPC glow with a specific color. If null, the glowing effect is removed.
* <p>
@@ -407,8 +339,8 @@ public class NpcOption<T, S extends Serializable>
* </ul>
* </p>
*/
public static final NpcOption<ChatColor, ChatColor> GLOWING = new NpcOption<>("glowing", null,
color -> color, color -> color,
public static final NpcOption<ChatColor, ChatColor> GLOWING = new NpcOption<>("glowing", () -> null,
color -> color, color -> color, color -> color,
(color, npc, player) ->
{
WrappedEntityData entityData = npc.entity.getEntityData();
@@ -436,24 +368,6 @@ public class NpcOption<T, S extends Serializable>
return new BundlePacket(teamPacket, SetEntityDataPacket.create(npc.entity.getId(), entityData));
});
/**
* NPC option to control the position of the NPC in the TAB list.
* <p>
* Only works on versions older than 1.21.2. On 1.21.2 and newer, this option has no effect.
* </p>
*/
public static final NpcOption<Integer, Integer> LIST_ORDER = new NpcOption<>("list-order", 0,
aInt -> aInt, aInt -> aInt,
(order, npc, player) ->
{
if(!Versions.isCurrentVersionSmallerThan(Versions.V1_21_2))
return null;
npc.getServerPlayer().setListOrder(order);
return new PlayerInfoUpdatePacket(PlayerInfoUpdatePacket.Action.UPDATE_LIST_ORDER, npc.getServerPlayer());
}).since(Versions.V1_21_2);
/**
* NPC option to control collision behavior between players and NPCs. When enabled, players can push and collide with NPCs. When disabled, players pass
* through NPCs without collision.
@@ -464,8 +378,8 @@ public class NpcOption<T, S extends Serializable>
* </ul>
* <p>
*/
public static final NpcOption<Boolean, Boolean> COLLISION = new NpcOption<>("collision", true,
aBoolean -> aBoolean, aBoolean -> aBoolean, (collision, npc, player) ->
public static final NpcOption<Boolean, Boolean> COLLISION = new NpcOption<>("collision", () -> true,
aBoolean -> aBoolean, aBoolean -> aBoolean, aBoolean -> aBoolean, (collision, npc, player) ->
{
var teamPair = getTeam(player, npc);
WrappedPlayerTeam team = teamPair.getFirst();
@@ -474,57 +388,155 @@ public class NpcOption<T, S extends Serializable>
});
/**
* NPC option to control if the NPC is enabled (visible and interactable). If false, a "DISABLED" marker may be shown. This is an internal option, typically
* not directly set by users but controlled by {@link NPC#setEnabled(boolean)}.
* NPC option to set the pose of the NPC (e.g., standing, sleeping, swimming). For a full list look at {@link Pose}.
*/
static final NpcOption<Boolean, Boolean> ENABLED = new NpcOption<>("enabled", false,
aBoolean -> aBoolean, aBoolean -> aBoolean,
(enabled, npc, player) ->
public static final NpcOption<Pose, Pose> POSE = new NpcOption<>("pose", () -> Pose.STANDING,
pose -> pose, pose -> pose, pose -> pose,
(pose, npc, player) ->
{
if(!Versions.isCurrentVersionSmallerThan(Versions.V1_19_4))
return null;
de.eisi05.npc.api.wrapper.enums.Pose nmsPose = de.eisi05.npc.api.wrapper.enums.Pose.fromBukkit(pose);
if(enabled)
return null;
if(nmsPose == null)
throw new RuntimeException("Pose (" + pose.name() + ") not found");
WrappedArmorStand armorStand = WrappedArmorStand.create(npc.entity.getBukkitPlayer().getLocation().getWorld());
armorStand.moveTo(npc.entity.getBukkitPlayer()
.getLocation()
.clone()
.add(0, (npc.getServerPlayer().getBoundingBox().getYSize() * npc.getOption(SCALE, player)), 0));
npc.getServerPlayer().setPose(nmsPose);
PacketWrapper addPacket = armorStand.getAddEntityPacket();
WrappedEntityData data = npc.entity.getEntityData();
data.set(WrappedEntityData.EntityDataSerializers.ENTITY_POSE.create(6), nmsPose);
WrappedEntityData data = armorStand.getEntityData();
WrappedEntityData.EntityDataAccessor<Byte> accessor = WrappedEntityData.EntityDataSerializers.BYTE.create(0);
Byte value = data.get(accessor);
byte flags = value == null ? 0 : value;
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(0), (byte) 0x20);
data.set(WrappedEntityData.EntityDataSerializers.OPTIONAL_CHAT_COMPONENT.create(2),
Optional.of(WrappedComponent.parseFromLegacy(NpcApi.DISABLED_MESSAGE_PROVIDER.apply(player)).getHandle()));
data.set(WrappedEntityData.EntityDataSerializers.BOOLEAN.create(3), true);
data.set(WrappedEntityData.EntityDataSerializers.BOOLEAN.create(4), true);
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(15), (byte) 0x10);
PacketWrapper packetWrapper = null;
if(pose == Pose.FALL_FLYING)
{
data.set(accessor, (byte) (flags | 0x80));
packetWrapper = new MoveEntityPacket.Rot(npc.entity.getId(), (byte) (npc.getLocation().getYaw() * 256 / 360),
(byte) 0, npc.getServerPlayer().isOnGround());
}
else if(pose == Pose.SWIMMING)
data.set(accessor, (byte) (flags | 0x10));
else
data.set(accessor, (byte) (flags & ~(0x80 | 0x10)));
npc.toDeleteEntities.put("name", armorStand.getId());
if(!npc.entity.getBukkitPlayer().getType().name().equals("ITEM_DISPLAY") &&
!npc.entity.getBukkitPlayer().getType().name().equals("BLOCK_DISPLAY"))
{
Byte handValue = data.get(WrappedEntityData.EntityDataSerializers.BYTE.create(8));
byte handFlag = handValue == null ? 0 : handValue;
return new BundlePacket(addPacket, SetEntityDataPacket.create(armorStand.getId(), data));
if(pose == Pose.SPIN_ATTACK)
{
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(8), (byte) (handFlag | 0x04));
packetWrapper = new MoveEntityPacket.Rot(npc.entity.getId(), (byte) (npc.getLocation().getYaw() * 256 / 360),
(byte) -90, npc.getServerPlayer().isOnGround());
}
else
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(8), (byte) (handFlag & ~0x04));
}
if(pose == Pose.SLEEPING)
{
new BukkitRunnable()
{
int counter = 255;
final Location startLocation = npc.getLocation().clone();
final float startYaw = startLocation.getYaw();
@Override
public void run()
{
startLocation.setYaw((startYaw + counter) % 360);
npc.updateLocationForPlayer(startLocation, player);
if(counter == 360)
cancel();
counter += 35;
}
}.runTaskTimer(NpcApi.plugin, 10, 5);
}
else
{
new BukkitRunnable()
{
@Override
public void run()
{
npc.updateLocationForPlayer(npc.getLocation(), player);
}
}.runTaskLater(NpcApi.plugin, 1);
}
Integer oldId = npc.toDeleteEntities.remove("sit");
if(nmsPose == de.eisi05.npc.api.wrapper.enums.Pose.SITTING)
{
WrappedTextDisplay textDisplay = WrappedTextDisplay.create(npc.getLocation().getWorld());
textDisplay.moveTo(npc.getLocation());
npc.toDeleteEntities.put("sit", textDisplay.getId());
PacketWrapper addEntityPacket = textDisplay.getAddEntityPacket();
WrappedEntityData wrappedEntityData = textDisplay.getEntityData();
wrappedEntityData.set(accessor, (byte) (flags | 0x20));
SetEntityDataPacket entityDataPacket = SetEntityDataPacket.create(textDisplay.getId(), wrappedEntityData);
textDisplay.setPassengers(npc.entity);
SetPassengerPacket passengerPacket = new SetPassengerPacket(textDisplay);
RotateHeadPacket rotateHeadPacket = new RotateHeadPacket(npc.entity, (byte) (npc.getLocation().getYaw() * 256 / 360));
if(oldId == null)
return new BundlePacket(addEntityPacket, entityDataPacket, passengerPacket, rotateHeadPacket);
return new BundlePacket(new RemoveEntityPacket(oldId), addEntityPacket, entityDataPacket, passengerPacket, rotateHeadPacket);
}
else
{
if(oldId == null)
return packetWrapper == null ? new BundlePacket(SetEntityDataPacket.create(npc.entity.getId(), data)) :
new BundlePacket(packetWrapper, SetEntityDataPacket.create(npc.entity.getId(), data));
return packetWrapper == null ?
new BundlePacket(new RemoveEntityPacket(oldId), SetEntityDataPacket.create(npc.entity.getId(), data)) :
new BundlePacket(packetWrapper, new RemoveEntityPacket(oldId), SetEntityDataPacket.create(npc.entity.getId(), data));
}
});
/**
* NPC option to control if the NPC is enabled (visible and interactable). If false, a "DISABLED" marker may be shown. This is an internal option, typically
* not directly set by users but controlled by {@link NPC#setEnabled(boolean)}.
* NPC option to set the scale (size) of the NPC. A value of 1.0 is normal size. Requires Minecraft 1.20.6 or newer.
*/
static final NpcOption<Boolean, Boolean> EDITABLE = new NpcOption<>("editable", false,
aBoolean -> aBoolean, aBoolean -> aBoolean,
(enabled, npc, player) -> null);
public static final NpcOption<Double, Double> SCALE = new NpcOption<>("scale", () -> 1.0,
scale -> scale, scale -> scale, scale -> scale,
(scale, npc, player) ->
{
if(npc.entity.getBukkitPlayer().getType().name().equals("ITEM_DISPLAY") ||
npc.entity.getBukkitPlayer().getType().name().equals("BLOCK_DISPLAY"))
return null;
WrappedAttributeInstance instance = npc.getServerPlayer().getAttribute(WrappedAttributeInstance.Attributes.SCALE_HOLDER);
instance.setBaseValue(scale);
return new UpdateAttributesPacket(npc.entity.getId(), instance);
}).since(Versions.V1_20_6);
/**
* NPC option to store custom data for the NPC. This is an internal option, typically not directly set by users but controlled by
* {@link NPC#addCustomData(Serializable, Serializable)}.
* NPC option to control the position of the NPC in the TAB list.
* <p>
* Only works on versions older than 1.21.2. On 1.21.2 and newer, this option has no effect.
* </p>
*/
static final NpcOption<HashMap<Serializable, Serializable>, HashMap<Serializable, Serializable>> CUSTOM_DATA = new NpcOption<>("custom-data",
new HashMap<>(),
aHashMap -> aHashMap, aHashMap -> aHashMap,
(customData, npc, player) -> null);
public static final NpcOption<Integer, Integer> LIST_ORDER = new NpcOption<>("list-order", () -> 0,
aInt -> aInt, aInt -> aInt, aInt -> aInt,
(order, npc, player) ->
{
if(!Versions.isCurrentVersionSmallerThan(Versions.V1_21_2))
return null;
npc.getServerPlayer().setListOrder(order);
return new PlayerInfoUpdatePacket(PlayerInfoUpdatePacket.Action.UPDATE_LIST_ORDER, npc.getServerPlayer());
}).since(Versions.V1_21_2);
/**
* NPC option to change the entity type of the NPC. This allows transforming the NPC into any Minecraft entity type. The default is a PLAYER entity. When
@@ -539,7 +551,7 @@ public class NpcOption<T, S extends Serializable>
* }</pre>
*/
public static final NpcOption<WrappedEntitySnapshot, WrappedEntitySnapshot> ENTITY = new NpcOption<>("entity",
new WrappedEntitySnapshot(EntityType.PLAYER),
() -> new WrappedEntitySnapshot(EntityType.PLAYER), wrappedEntitySnapshot -> wrappedEntitySnapshot,
wrappedEntitySnapshot -> wrappedEntitySnapshot, wrappedEntitySnapshot -> wrappedEntitySnapshot,
(wrappedEntitySnapshot, npc, player) ->
{
@@ -567,7 +579,8 @@ public class NpcOption<T, S extends Serializable>
var teamPair = getTeam(player, npc);
WrappedPlayerTeam team = teamPair.getFirst();
team.setNameTagVisibility(WrappedPlayerTeam.Visibility.NEVER);
if(team.getNameTagVisibility() != WrappedPlayerTeam.Visibility.NEVER)
team.setNameTagVisibility(WrappedPlayerTeam.Visibility.NEVER);
if(!team.getName().startsWith("trans"))
team.getPlayers().add(npc.getServerPlayer().getName());
@@ -579,10 +592,6 @@ public class NpcOption<T, S extends Serializable>
packets.add(SetPlayerTeamPacket.createPlayerPacket(team, npc.entity.getBukkitPlayer().getUniqueId().toString(),
SetPlayerTeamPacket.Action.ADD));
packets.add(new RotateHeadPacket(entity, (byte) ((npc.getLocation().getYaw() % 360) * 256 / 360)));
packets.add(new MoveEntityPacket.Rot(entity.getId(), (byte) npc.getLocation().getYaw(),
(byte) npc.getLocation().getPitch(), npc.getServerPlayer().isOnGround()));
WrappedEntityData data = entity.getEntityData();
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(0),
@@ -595,7 +604,7 @@ public class NpcOption<T, S extends Serializable>
{
if(Versions.isCurrentVersionSmallerThan(Versions.V1_21))
npc.serverPlayer.getNameTag()
.moveTo(entity.getBukkitPlayer().getLocation().clone().add(0,
.moveTo(npc.getLocation().clone().add(0,
(entity.getBoundingBox().getYSize() * npc.getOption(NpcOption.SCALE, player)), 0));
packets.add(npc.serverPlayer.getNameTag().getAddEntityPacket());
@@ -603,7 +612,7 @@ public class NpcOption<T, S extends Serializable>
packets.add(SetEntityDataPacket.create(npc.serverPlayer.getNameTag().getId(), npc.serverPlayer.getNameTag().applyData(
Versions.isCurrentVersionSmallerThan(Versions.V1_19_4) || npc.isEnabled() ? npc.name.getName(player) :
WrappedComponent.parseFromLegacy(NpcApi.DISABLED_MESSAGE_PROVIDER.apply(player))
.append(WrappedComponent.create("\n").append(npc.name.getName(player))))));
.append(WrappedComponent.create("\n").append(npc.name.getName(player))))));
if(!Versions.isCurrentVersionSmallerThan(Versions.V1_19_4))
{
@@ -615,10 +624,91 @@ public class NpcOption<T, S extends Serializable>
return new BundlePacket(packets.toArray(new PacketWrapper[0]));
}).loadBefore(true);
/**
* NPC option to control if the NPC is enabled (visible and interactable). If false, a "DISABLED" marker may be shown. This is an internal option, typically
* not directly set by users but controlled by {@link NPC#setEnabled(boolean)}.
*/
static final NpcOption<Boolean, Boolean> ENABLED = new NpcOption<>("enabled", () -> false,
aBoolean -> aBoolean, aBoolean -> aBoolean, aBoolean -> aBoolean,
(enabled, npc, player) ->
{
if(!Versions.isCurrentVersionSmallerThan(Versions.V1_19_4))
return null;
if(enabled)
return null;
WrappedArmorStand armorStand = WrappedArmorStand.create(npc.getLocation().getWorld());
armorStand.moveTo(npc.getLocation().clone()
.add(0, (npc.entity.getBoundingBox().getYSize() * npc.getOption(SCALE, player)), 0));
PacketWrapper addPacket = armorStand.getAddEntityPacket();
WrappedEntityData data = armorStand.getEntityData();
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(0), (byte) 0x20);
data.set(WrappedEntityData.EntityDataSerializers.OPTIONAL_CHAT_COMPONENT.create(2),
Optional.of(WrappedComponent.parseFromLegacy(NpcApi.DISABLED_MESSAGE_PROVIDER.apply(player)).getHandle()));
data.set(WrappedEntityData.EntityDataSerializers.BOOLEAN.create(3), true);
data.set(WrappedEntityData.EntityDataSerializers.BOOLEAN.create(4), true);
data.set(WrappedEntityData.EntityDataSerializers.BYTE.create(15), (byte) 0x10);
npc.toDeleteEntities.put("name", armorStand.getId());
return new BundlePacket(addPacket, SetEntityDataPacket.create(armorStand.getId(), data));
});
/**
* NPC option to control if the NPC is enabled (visible and interactable). If false, a "DISABLED" marker may be shown. This is an internal option, typically
* not directly set by users but controlled by {@link NPC#setEnabled(boolean)}.
*/
static final NpcOption<Boolean, Boolean> EDITABLE = new NpcOption<>("editable", () -> false,
aBoolean -> aBoolean, aBoolean -> aBoolean, aBoolean -> aBoolean,
(enabled, npc, player) -> null);
/**
* NPC option to store the goal selector for the NPC. This allows saving and restoring the NPC's AI behavior. The serialized form stores the running state,
* tick interval, and goal configurations. Note: Goals themselves are not fully serialized - only their class names and any serializable configuration. On
* deserialization, goals must be re-instantiated by the plugin.
*/
static final NpcOption<ArrayList<Goal>, ArrayList<Goal>> GOALS = new NpcOption<>("goals", ArrayList::new,
goals -> new ArrayList<>(goals.stream().map(Goal::copy).toList()), goals -> goals,
goals -> goals,
(data, npc, player) -> null);
/**
* NPC option to store custom data for the NPC. This is an internal option, typically not directly set by users but controlled by
* {@link NPC#addCustomData(Serializable, Serializable)}.
*/
static final NpcOption<HashMap<Serializable, Serializable>, HashMap<Serializable, Serializable>> CUSTOM_DATA = new NpcOption<>("custom-data",
HashMap::new, HashMap::new,
aHashMap -> aHashMap, aHashMap -> aHashMap,
(customData, npc, player) -> null);
/**
* NPC option to manage visibility settings for the NPC. This controls whether the NPC should be shown to all players (including new ones) or only to specific players.
*/
static final NpcOption<NpcVisibilityManager, NpcVisibilityManager> VISIBILITY_MANAGER = new NpcOption<>("visibility-manager", NpcVisibilityManager::new,
npcVisibilityManager ->
{
NpcVisibilityManager newManager = new NpcVisibilityManager();
newManager.setShowToAllPlayers(npcVisibilityManager.shouldShowToAllPlayers());
npcVisibilityManager.getSpecificPlayers().forEach(newManager::addSpecificPlayer);
return newManager;
},visibilityManager -> visibilityManager, visibilityManager ->
{
if(visibilityManager.getSpecificPlayers().isEmpty() && !visibilityManager.shouldShowToAllPlayers())
visibilityManager.setShowToAllPlayers(true);
return visibilityManager;
},
(visibilityManager, npc, player) -> null);
private final String path;
private final T defaultValue;
private final Supplier<T> defaultValue;
private final Function<T, S> serializer;
private final Function<S, T> deserializer;
private final Function<T, T> copyFunction;
private final TriFunction<T, NPC, Player, PacketWrapper> packet;
private Versions since = Versions.V1_17;
private boolean loadBefore = false;
@@ -628,15 +718,17 @@ public class NpcOption<T, S extends Serializable>
*
* @param path The configuration path string. Must not be null.
* @param defaultValue The default value for the option. Can be null.
* @param copyFunction The copy function. Must not be null.
* @param serializer The serialization function. Must not be null.
* @param deserializer The deserialization function. Must not be null.
* @param packet The packet generation function. Must not be null.
*/
private NpcOption(@NotNull String path, @Nullable T defaultValue, @NotNull Function<T, S> serializer, @NotNull Function<S, T> deserializer,
@NotNull TriFunction<T, NPC, Player, PacketWrapper> packet)
private NpcOption(@NotNull String path, @Nullable Supplier<T> defaultValue, @NotNull Function<T, T> copyFunction,
@NotNull Function<T, S> serializer, @NotNull Function<S, T> deserializer, @NotNull TriFunction<T, NPC, Player, PacketWrapper> packet)
{
this.path = path;
this.defaultValue = defaultValue;
this.copyFunction = copyFunction;
this.serializer = serializer;
this.deserializer = deserializer;
this.packet = packet;
@@ -644,7 +736,7 @@ public class NpcOption<T, S extends Serializable>
private static @NotNull Pair<WrappedPlayerTeam, Boolean> getTeam(@NotNull Player player, @NotNull NPC npc)
{
boolean isTransparent = npc.getOption(VISIBILITY) == NpcVisibility.TRANSPARENT;
boolean isTransparent = npc.getOption(VISIBILITY, player) == NpcVisibility.TRANSPARENT;
WrappedPlayerTeam playerTeam = WrappedPlayerTeam.getPlayersTeam(player);
String teamName = isTransparent ? "trans-" + player.getEntityId() : npc.getServerPlayer().getName();
boolean modified = WrappedPlayerTeam.exists(player, teamName) || (playerTeam.getHandle() != null && isTransparent);
@@ -700,12 +792,23 @@ public class NpcOption<T, S extends Serializable>
return this;
}
/**
* Sets whether this option should be loaded before other options.
*
* @param loadBefore Whether this option should be loaded before other options.
* @return This {@link NpcOption} instance for method chaining.
*/
public @NotNull NpcOption<T, S> loadBefore(boolean loadBefore)
{
this.loadBefore = loadBefore;
return this;
}
/**
* Returns whether this option should be loaded before other options.
*
* @return {@code true} if this option should be loaded before other options, {@code false} otherwise.
*/
public boolean loadBefore()
{
return loadBefore;
@@ -739,7 +842,18 @@ public class NpcOption<T, S extends Serializable>
*/
public @Nullable T getDefaultValue()
{
return defaultValue;
return defaultValue.get();
}
/**
* Creates a copy of the current value
*
* @param value The current value which should be copied
* @return A copy of the current value
*/
public @NotNull T copy(@NotNull T value)
{
return copyFunction.apply(value);
}
/**
@@ -815,4 +929,4 @@ public class NpcOption<T, S extends Serializable>
{
return getPath().hashCode();
}
}
}
@@ -5,17 +5,12 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.scheduler.Tasks;
import de.eisi05.npc.api.utils.Reflections;
import de.eisi05.npc.api.utils.Versions;
import de.eisi05.npc.api.utils.exceptions.VersionNotFound;
import de.eisi05.npc.api.wrapper.objects.WrappedServerPlayer;
import org.apache.http.HttpEntity;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -24,16 +19,21 @@ import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Scanner;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Represents a player's skin, containing its name, value, and signature.
* This record is immutable and implements {@link Serializable} for easy persistence.
* The skin value and signature are typically obtained from Mojang's session servers
* and are used to display the correct player texture.
* Represents a player's skin, containing its name, value, and signature. This record is immutable and implements {@link Serializable} for easy persistence. The
* skin value and signature are typically obtained from Mojang's session servers and are used to display the correct player texture.
*
* @param name The name associated with the skin (usually the player's username). Can be {@code null}.
* @param value The base64 encoded string representing the skin data (texture URL, model, etc.).
@@ -45,16 +45,14 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
private static final long serialVersionUID = 1L;
/**
* A static cache to store fetched skins, mapping UUIDs to Skin objects.
* This helps reduce redundant API calls to Mojang's servers.
* A static cache to store fetched skins, mapping UUIDs to Skin objects. This helps reduce redundant API calls to Mojang's servers.
*/
private static final Map<String, Skin> skinCacheName = new ConcurrentHashMap<>();
private static final Map<UUID, Skin> skinCacheUUID = new ConcurrentHashMap<>();
private static final Map<File, Skin> skinCacheFile = new ConcurrentHashMap<>();
/**
* Retrieves the skin data directly from a currently online Bukkit player.
* This method uses reflection to access the player's game profile properties.
* Retrieves the skin data directly from a currently online Bukkit player. This method uses reflection to access the player's game profile properties.
*
* @param player The Bukkit player from whom to retrieve the skin. Must not be {@code null}.
* @return A {@link Skin} object representing the player's current skin, or {@code null} if no skin properties are found.
@@ -120,7 +118,8 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
return Optional.empty();
}
} catch(IOException e)
}
catch(IOException e)
{
skinCacheUUID.remove(uuid);
return Optional.empty();
@@ -153,7 +152,8 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
"(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})",
"$1-$2-$3-$4-$5")));
}
} catch(IOException e)
}
catch(IOException e)
{
skinCacheName.remove(name);
return Optional.empty();
@@ -175,57 +175,41 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
if(skinCacheFile.containsKey(skinFile))
return Optional.ofNullable(skinCacheFile.get(skinFile));
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(10_000)
.setSocketTimeout(10_000)
.build();
String boundary = "----MineSkinBoundary" + System.currentTimeMillis();
try(CloseableHttpClient httpClient = HttpClients.custom()
.setDefaultRequestConfig(requestConfig)
.build();
FileInputStream fis = new FileInputStream(skinFile))
try
{
HttpPost upload = new HttpPost("https://api.mineskin.org/generate/upload");
upload.setHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
String boundary = "----MineSkinBoundary" + System.currentTimeMillis();
String CRLF = "\r\n";
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(baos, StandardCharsets.UTF_8), true);
byte[] fileBytes = Files.readAllBytes(skinFile.toPath());
String bodyBuilder = "--" + boundary + CRLF +
"Content-Disposition: form-data; name=\"file\"; filename=\"" +
skinFile.getName() + "\"" + CRLF +
"Content-Type: image/png" + CRLF + CRLF;
writer.append("--").append(boundary).append("\r\n");
writer.append("Content-Disposition: form-data; name=\"file\"; filename=\"")
.append(skinFile.getName()).append("\"\r\n");
writer.append("Content-Type: image/png\r\n\r\n");
writer.flush();
String endPart = CRLF + "--" + boundary + "--" + CRLF;
byte[] buffer = new byte[4096];
int len;
while((len = fis.read(buffer)) != -1)
baos.write(buffer, 0, len);
baos.flush();
byte[] requestBody = combine(bodyBuilder.getBytes(), fileBytes, endPart.getBytes());
writer.append("\r\n--").append(boundary).append("--\r\n");
writer.flush();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.mineskin.org/generate/upload"))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(requestBody))
.build();
upload.setEntity(new ByteArrayEntity(baos.toByteArray()));
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
JsonObject obj = new JsonParser().parse(response.body()).getAsJsonObject();
JsonObject texture = obj.getAsJsonObject("data").getAsJsonObject("texture");
return httpClient.execute(upload, response ->
{
HttpEntity responseEntity = response.getEntity();
String json = EntityUtils.toString(responseEntity);
String value = texture.get("value").getAsString();
String signature = texture.get("signature").getAsString();
JsonObject obj = new JsonParser().parse(json).getAsJsonObject();
JsonObject texture = obj.getAsJsonObject("data").getAsJsonObject("texture");
String value = texture.get("value").getAsString();
String signature = texture.get("signature").getAsString();
Skin skin = new Skin(null, value, signature);
skinCacheFile.put(skinFile, skin);
return Optional.of(skin);
});
} catch(IOException e)
Skin skin = new Skin(null, value, signature);
skinCacheFile.put(skinFile, skin);
return Optional.of(skin);
}
catch(IOException | InterruptedException e)
{
if(NpcApi.config.debug())
e.printStackTrace();
@@ -235,14 +219,19 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
}
}
/**
* Converts this Skin to a NpcSkin.
*
* @return the NpcSkin representation of this Skin
*/
public NpcSkin toNpcSkin()
private static byte[] combine(byte[]... arrays) throws IOException
{
return NpcSkin.of(this);
int length = 0;
for(byte[] arr : arrays)
length += arr.length;
byte[] result = new byte[length];
int pos = 0;
for(byte[] arr : arrays)
{
System.arraycopy(arr, 0, result, pos, arr.length);
pos += arr.length;
}
return result;
}
/**
@@ -253,7 +242,10 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
*/
public static CompletableFuture<Optional<Skin>> fetchSkinAsync(@NotNull UUID uuid)
{
return CompletableFuture.supplyAsync(() -> fetchSkin(uuid));
CompletableFuture<Optional<Skin>> future = CompletableFuture.supplyAsync(() -> fetchSkin(uuid),
runnable -> Bukkit.getScheduler().runTaskAsynchronously(NpcApi.plugin, runnable));
Tasks.trackFuture(future);
return future;
}
/**
@@ -264,7 +256,10 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
*/
public static CompletableFuture<Optional<Skin>> fetchSkinAsync(@NotNull String name)
{
return CompletableFuture.supplyAsync(() -> fetchSkin(name));
CompletableFuture<Optional<Skin>> future = CompletableFuture.supplyAsync(() -> fetchSkin(name),
runnable -> Bukkit.getScheduler().runTaskAsynchronously(NpcApi.plugin, runnable));
Tasks.trackFuture(future);
return future;
}
/**
@@ -275,7 +270,10 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
*/
public static CompletableFuture<Optional<Skin>> fetchSkinAsync(@NotNull File skinFile)
{
return CompletableFuture.supplyAsync(() -> fetchSkin(skinFile));
CompletableFuture<Optional<Skin>> future = CompletableFuture.supplyAsync(() -> fetchSkin(skinFile),
runnable -> Bukkit.getScheduler().runTaskAsynchronously(NpcApi.plugin, runnable));
Tasks.trackFuture(future);
return future;
}
/**
@@ -299,4 +297,14 @@ public record Skin(@Nullable String name, @Nullable String value, @Nullable Stri
{
return skinCacheUUID.containsKey(uuid);
}
/**
* Converts this Skin to a NpcSkin.
*
* @return the NpcSkin representation of this Skin
*/
public NpcSkin toNpcSkin()
{
return NpcSkin.of(this);
}
}
@@ -1,13 +1,12 @@
package de.eisi05.npc.api.pathfinding;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.utils.Var;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Openable;
import org.bukkit.util.BoundingBox;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -15,43 +14,145 @@ import java.util.*;
public class AStarPathfinder
{
private static final double[][][] MOVE_COSTS = new double[3][3][3];
static
{
for(int x = -1; x <= 1; x++)
{
for(int y = -1; y <= 1; y++)
{
for(int z = -1; z <= 1; z++)
{
if(x == 0 && y == 0 && z == 0)
MOVE_COSTS[x + 1][y + 1][z + 1] = 0;
else
MOVE_COSTS[x + 1][y + 1][z + 1] = (Math.abs(x) + Math.abs(y) + Math.abs(z)) > 1 ? 1.414 : 1.0;
}
}
}
}
private final int maxIterations;
private final boolean allowDiagonal;
private final double entityHeight;
private final double entityWidth;
private final PriorityQueue<Node> openSet = new PriorityQueue<>();
private final Set<Long> openSetIds = new HashSet<>();
private final Map<Long, Node> allNodes = new HashMap<>();
private World world;
private final PriorityQueue<Node> openSet = new PriorityQueue<>();
private final Map<Long, Node> allNodes = new HashMap<>();
public AStarPathfinder(int maxIterations, boolean allowDiagonal)
public AStarPathfinder(int maxIterations, boolean allowDiagonal, double entityHeight, double entityWidth)
{
this.maxIterations = maxIterations;
this.allowDiagonal = allowDiagonal;
this.entityHeight = entityHeight;
this.entityWidth = entityWidth;
}
/**
* Checks if a block is valid to stand on.
*/
public static boolean isSafeFloor(Block block)
{
if(block == null)
return false;
Material type = block.getType();
if(type.isAir() || block.isLiquid())
return false;
return !block.isPassable();
}
/**
* Checks if a position is valid (not inside a solid block).
*
* @param world The world to check in
* @param tx The x coordinate of the position
* @param ty The y coordinate of the position
* @param tz The z coordinate of the position
* @param entityHeight The height of the entity
* @param entityWidth The width of the entity
* @return true if the position is valid, false otherwise
*/
public static boolean isPositionValid(@NotNull World world, double tx, double ty, double tz, double entityHeight, double entityWidth)
{
double radius = entityWidth / 2.0;
double minX = tx - radius;
double maxX = tx + radius;
double maxY = ty + entityHeight;
double minZ = tz - radius;
double maxZ = tz + radius;
BoundingBox entityBox = new BoundingBox(minX, ty, minZ, maxX, maxY, maxZ);
int minBlockX = (int) Math.floor(minX);
int maxBlockX = (int) Math.floor(maxX);
int minBlockY = (int) Math.floor(ty);
int maxBlockY = (int) Math.floor(maxY);
if(maxY > maxBlockY && maxBlockY == minBlockY)
maxBlockY++;
int minBlockZ = (int) Math.floor(minZ);
int maxBlockZ = (int) Math.floor(maxZ);
for(int x = minBlockX; x <= maxBlockX; x++)
{
for(int y = minBlockY; y <= maxBlockY; y++)
{
for(int z = minBlockZ; z <= maxBlockZ; z++)
{
Block block = world.getBlockAt(x, y, z);
if(block.getBlockData() instanceof Openable)
continue;
Collection<BoundingBox> blockBoxes = block.getCollisionShape().getBoundingBoxes();
if(blockBoxes.isEmpty())
continue;
for(BoundingBox blockBox : blockBoxes)
{
BoundingBox absoluteBox = blockBox.clone().shift(x, y, z);
if(entityBox.overlaps(absoluteBox))
return false;
}
}
}
}
return true;
}
public @Nullable List<Location> getPath(@NotNull Location start, @NotNull Location end) throws PathfindingUtils.PathfindingException
{
if(start.getWorld() == null || end.getWorld() == null)
return null;
if(!start.getWorld().equals(end.getWorld()))
return null;
openSet.clear();
openSetIds.clear();
allNodes.clear();
this.world = start.getWorld();
int startOffset = Math.abs(start.getY() - start.getBlockY()) > 0 ? 0 : 1;
Block startFloor = world.getBlockAt(start.getBlockX(), start.getBlockY() - startOffset, start.getBlockZ());
int startFloorY = resolveFloorY(start);
int endFloorY = resolveFloorY(end);
Block startFloor = world.getBlockAt(start.getBlockX(), startFloorY, start.getBlockZ());
if(NpcApi.config.checkValidPath() && !isSafeFloor(startFloor))
throw new PathfindingUtils.PathfindingException("Start not on a valid floor: " + start);
int endOffset = Math.abs(end.getY() - end.getBlockY()) > 0 ? 0 : 1;
Block endFloor = world.getBlockAt(end.getBlockX(), end.getBlockY() - endOffset, end.getBlockZ());
Block endFloor = world.getBlockAt(end.getBlockX(), endFloorY, end.getBlockZ());
if(NpcApi.config.checkValidPath() && !isSafeFloor(endFloor))
throw new PathfindingUtils.PathfindingException("End not on a valid floor: " + end);
Node startNode = new Node(start.getBlockX(), start.getBlockY(), start.getBlockZ(), null);
Node startNode = new Node(start.getBlockX(), startFloorY, start.getBlockZ(), null);
startNode.gCost = 0;
startNode.calculateH(end);
openSet.add(startNode);
openSetIds.add(startNode.id);
allNodes.put(startNode.id, startNode);
int iterations = 0;
@@ -64,8 +165,9 @@ public class AStarPathfinder
iterations++;
Node current = openSet.poll();
openSetIds.remove(current.id);
if(distance(current, end) < 2)
if(distanceSq(current, end) < 1.0)
return retracePath(current);
current.closed = true;
@@ -79,7 +181,7 @@ public class AStarPathfinder
if(x == 0 && y == 0 && z == 0)
continue;
if(!allowDiagonal && (Math.abs(x) + Math.abs(z) > 1.5))
if(!allowDiagonal && (Math.abs(x) + Math.abs(z) > 1))
continue;
int targetX = current.x + x;
@@ -90,24 +192,29 @@ public class AStarPathfinder
continue;
long id = Node.hash(targetX, targetY, targetZ);
Node neighbor = allNodes.getOrDefault(id, new Node(targetX, targetY, targetZ, id));
Node neighbor = allNodes.get(id);
if(neighbor == null)
{
neighbor = new Node(targetX, targetY, targetZ, id);
allNodes.put(id, neighbor);
}
if(neighbor.closed)
continue;
double moveCost = (Math.abs(x) + Math.abs(y) + Math.abs(z)) > 1 ? 1.414 : 1.0;
double newGCost = current.gCost + moveCost;
double newGCost = current.gCost + MOVE_COSTS[x + 1][y + 1][z + 1];
if(newGCost < neighbor.gCost || !openSet.contains(neighbor))
if(newGCost < neighbor.gCost || !openSetIds.contains(id))
{
neighbor.gCost = newGCost;
neighbor.calculateH(end);
neighbor.parent = current;
if(!openSet.contains(neighbor))
if(!openSetIds.contains(id))
{
openSet.add(neighbor);
allNodes.put(id, neighbor);
openSetIds.add(id);
}
}
}
@@ -118,26 +225,40 @@ public class AStarPathfinder
}
/**
* Advanced physics check.
* Checks if we can move from (fx, fy, fz) to (tx, ty, tz).
* Advanced physics check. Checks whether we can move from one floor block to another.
* <p>
* The {@code fy} and {@code ty} values are floor-block Y coordinates. Entity feet and headspace are checked at {@code ty + 1} and {@code ty + 2}.
*/
private boolean canWalk(int fx, int fy, int fz, int tx, int ty, int tz)
{
Block floor = world.getBlockAt(tx, ty, tz);
Block spaceFeet = world.getBlockAt(tx, ty + 1, tz);
Block spaceHead = world.getBlockAt(tx, ty + 2, tz);
if(!isSafeFloor(floor))
return false;
if(isSolid(spaceFeet) || isSolid(spaceHead))
double absoluteFeetY = feetYAt(tx, ty, tz);
if(!isPositionValid(world, tx + 0.5, absoluteFeetY, tz + 0.5, entityHeight, entityWidth))
return false;
if(fx != tx && fz != tz)
{
Block checkA = world.getBlockAt(fx, ty + 1, tz);
Block checkB = world.getBlockAt(tx, ty + 1, fz);
if(isSolid(checkA) || isSolid(checkB))
double currentFeetY = feetYAt(fx, fy, fz);
double checkY = Math.max(currentFeetY, absoluteFeetY);
if(!isPositionValid(world, fx + 0.5, checkY, tz + 0.5, entityHeight, entityWidth))
return false;
if(!isPositionValid(world, tx + 0.5, checkY, fz + 0.5, entityHeight, entityWidth))
return false;
}
if(fy != ty)
{
double currentFeetY = feetYAt(fx, fy, fz);
double highestFloorY = Math.max(currentFeetY, absoluteFeetY);
if(!isPositionValid(world, tx + 0.5, highestFloorY, tz + 0.5, entityHeight, entityWidth) ||
!isPositionValid(world, fx + 0.5, highestFloorY, fz + 0.5, entityHeight, entityWidth))
return false;
}
@@ -145,48 +266,52 @@ public class AStarPathfinder
}
/**
* Checks if a block is valid to stand ON.
* Resolves the block Y coordinate of the floor beneath a feet-based location. This keeps path nodes aligned with partial collision blocks such as slabs and
* stairs.
*
* @param loc the feet-based location to inspect
* @return the Y coordinate of the floor block
*/
private boolean isSafeFloor(Block block)
private int resolveFloorY(@NotNull Location loc)
{
if(block == null)
return false;
World w = loc.getWorld();
if(w == null)
return loc.getBlockY() - 1;
Material type = block.getType();
if(type.isAir() || block.isLiquid())
return false;
int bx = loc.getBlockX();
int bz = loc.getBlockZ();
int startY = loc.getBlockY();
if(block.isPassable())
return false;
double lx = loc.getX() - bx;
double lz = loc.getZ() - bz;
return true;
}
for(int y = startY + 1; y >= startY - 6; y--)
{
Block block = w.getBlockAt(bx, y, bz);
/**
* Checks if a block obstructs movement (is a wall).
*/
private boolean isSolid(Block block)
{
if(block == null)
return false;
if(block.getBlockData() instanceof Openable)
continue;
Material type = block.getType();
if(type.isAir())
return false;
if(block.isLiquid())
continue;
if(Var.isCarpet(type) && Var.isCarpet(block.getRelative(BlockFace.UP).getType()))
return true;
if(!block.getType().isSolid() || block.isPassable())
continue;
if(Var.isCarpet(type))
return false;
Collection<BoundingBox> boxes = block.getCollisionShape().getBoundingBoxes();
if(boxes.isEmpty())
return y;
if(block.isPassable())
return false;
for(BoundingBox bb : boxes)
{
if(lx >= bb.getMinX() && lx <= bb.getMaxX() && lz >= bb.getMinZ() && lz <= bb.getMaxZ())
return y;
}
if(block.getBlockData() instanceof Openable)
return false;
return y;
}
return true;
return loc.getBlockY() - 1;
}
private @NotNull List<Location> retracePath(@NotNull Node current)
@@ -194,19 +319,51 @@ public class AStarPathfinder
List<Location> path = new ArrayList<>();
while(current != null)
{
path.add(new Location(world, current.x + 0.5, current.y, current.z + 0.5));
double feetY = feetYAt(current.x, current.y, current.z);
path.add(new Location(world, current.x + 0.5, feetY, current.z + 0.5));
current = current.parent;
}
Collections.reverse(path);
return path;
}
private double distance(@NotNull Node n, @NotNull Location l)
private double feetYAt(int x, int floorY, int z)
{
double dx = (n.x + 0.5) - l.getBlockX();
double dy = n.y - l.getBlockY();
double dz = (n.z + 0.5) - l.getBlockZ();
Block floor = world.getBlockAt(x, floorY, z);
return floorY + topSurfaceAt(floor, 0.5, 0.5);
}
private double topSurfaceAt(@NotNull Block block, double lx, double lz)
{
Collection<BoundingBox> boxes = block.getCollisionShape().getBoundingBoxes();
if(boxes.isEmpty())
return 1.0;
double bestTop = -1.0;
for(BoundingBox bb : boxes)
{
if(lx >= bb.getMinX() && lx <= bb.getMaxX() && lz >= bb.getMinZ() && lz <= bb.getMaxZ())
bestTop = Math.max(bestTop, bb.getMaxY());
}
if(bestTop < 0.0)
{
for(BoundingBox bb : boxes)
bestTop = Math.max(bestTop, bb.getMaxY());
}
if(bestTop <= 0.0)
return 1.0;
return bestTop;
}
private double distanceSq(@NotNull Node n, @NotNull Location l)
{
double dx = (n.x + 0.5) - l.getX();
double dy = feetYAt(n.x, n.y, n.z) - l.getY();
double dz = (n.z + 0.5) - l.getZ();
return dx * dx + dy * dy + dz * dz;
}
@@ -235,7 +392,10 @@ public class AStarPathfinder
public void calculateH(@NotNull Location end)
{
this.hCost = Math.hypot(Math.hypot(x - end.getBlockX(), y - end.getBlockY()), z - end.getBlockZ());
double dx = (x + 0.5) - end.getX();
double dy = (y + 1.0) - end.getY();
double dz = (z + 0.5) - end.getZ();
this.hCost = Math.sqrt(dx * dx + dy * dy + dz * dz);
}
public double getFCost()
@@ -249,4 +409,4 @@ public class AStarPathfinder
return Double.compare(this.getFCost(), other.getFCost());
}
}
}
}
@@ -170,7 +170,7 @@ public class Path implements ConfigurationSerializable
return name;
}
private static class SerializableLocation implements Serializable
public static class SerializableLocation implements Serializable
{
@Serial
private static final long serialVersionUID = 1L;
@@ -1,5 +1,7 @@
package de.eisi05.npc.api.pathfinding;
import de.eisi05.npc.api.NpcApi;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -10,17 +12,15 @@ import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
/**
* Utility class for calculating paths between locations using A* pathfinding.
* Provides synchronous and asynchronous methods.
* Utility class for calculating paths between locations using A* pathfinding. Provides synchronous and asynchronous methods.
*/
public class PathfindingUtils
{
/**
* Asynchronously calculates a path through a list of waypoints.
* <p>
* Each segment between consecutive waypoints is calculated in parallel using {@link CompletableFuture}.
* The returned future completes with a {@link Path} containing the full path, or completes exceptionally
* if an {@link PathfindingException} occurs.
* Each segment between consecutive waypoints is calculated in parallel using {@link CompletableFuture}. The returned future completes with a {@link Path}
* containing the full path, or completes exceptionally if an {@link PathfindingException} occurs.
*
* @param waypoints the ordered list of locations to traverse
* @param maxIterations the maximum number of iterations the A* algorithm will attempt per segment
@@ -29,25 +29,57 @@ public class PathfindingUtils
* @return a {@link CompletableFuture} that completes with the calculated {@link Path}
*/
public static @NotNull CompletableFuture<Path> findPathAsync(@NotNull List<Location> waypoints, int maxIterations, boolean allowDiagonalMovement,
@Nullable BiConsumer<Integer, Integer> progressListener)
@Nullable BiConsumer<Integer, Integer> progressListener)
{
return CompletableFuture.supplyAsync(() ->
{
try
{
return findPath(waypoints, maxIterations, allowDiagonalMovement, progressListener);
} catch(PathfindingException e)
}
catch(PathfindingException e)
{
throw new RuntimeException(e);
}
});
}, runnable -> Bukkit.getScheduler().runTaskAsynchronously(NpcApi.plugin, runnable));
}
/**
* Asynchronously calculates a path through a list of waypoints.
* <p>
* Each segment between consecutive waypoints is calculated in parallel using {@link CompletableFuture}. The returned future completes with a {@link Path}
* containing the full path, or completes exceptionally if an {@link PathfindingException} occurs.
*
* @param waypoints the ordered list of locations to traverse
* @param maxIterations the maximum number of iterations the A* algorithm will attempt per segment
* @param allowDiagonalMovement whether diagonal movement is allowed
* @param entityHeight the height of the entity traversing the path
* @param entityWidth the width of the entity traversing the path
* @param progressListener a progress listener with the signature (segmentIndex, totalSegments)
* @return a {@link CompletableFuture} that completes with the calculated {@link Path}
*/
public static @NotNull CompletableFuture<Path> findPathAsync(@NotNull List<Location> waypoints, int maxIterations, boolean allowDiagonalMovement,
double entityHeight, double entityWidth,
@Nullable BiConsumer<Integer, Integer> progressListener)
{
return CompletableFuture.supplyAsync(() ->
{
try
{
return findPath(waypoints, maxIterations, allowDiagonalMovement, entityHeight, entityWidth, progressListener);
}
catch(PathfindingException e)
{
throw new RuntimeException(e);
}
}, runnable -> Bukkit.getScheduler().runTaskAsynchronously(NpcApi.plugin, runnable));
}
/**
* Synchronously calculates a path through a list of waypoints.
* <p>
* Each segment between consecutive waypoints is calculated in parallel internally using {@link CompletableFuture},
* but this method blocks until all segments are calculated and combined into a single {@link Path}.
* Each segment between consecutive waypoints is calculated in parallel internally using {@link CompletableFuture}, but this method blocks until all
* segments are calculated and combined into a single {@link Path}.
*
* @param waypoints the ordered list of locations to traverse
* @param maxIterations the maximum number of iterations the A* algorithm will attempt per segment
@@ -57,14 +89,36 @@ public class PathfindingUtils
* @throws PathfindingException if any segment's start or end location is invalid/unwalkable
*/
public static @NotNull Path findPath(@NotNull List<Location> waypoints, int maxIterations, boolean allowDiagonalMovement,
@Nullable BiConsumer<Integer, Integer> progressListener) throws PathfindingException
@Nullable BiConsumer<Integer, Integer> progressListener) throws PathfindingException
{
return findPath(waypoints, maxIterations, allowDiagonalMovement, 1.8, 0.6, progressListener);
}
/**
* Synchronously calculates a path through a list of waypoints.
* <p>
* Each segment between consecutive waypoints is calculated in parallel internally using {@link CompletableFuture}, but this method blocks until all
* segments are calculated and combined into a single {@link Path}.
*
* @param waypoints the ordered list of locations to traverse
* @param maxIterations the maximum number of iterations the A* algorithm will attempt per segment
* @param allowDiagonalMovement whether diagonal movement is allowed
* @param entityHeight the height of the entity traversing the path
* @param entityWidth the width of the entity traversing the path
* @param progressListener a progress listener with the signature (segmentIndex, totalSegments)
* @return the calculated {@link Path} containing all intermediate locations
* @throws PathfindingException if any segment's start or end location is invalid/unwalkable
*/
public static @NotNull Path findPath(@NotNull List<Location> waypoints, int maxIterations, boolean allowDiagonalMovement,
double entityHeight, double entityWidth, @Nullable BiConsumer<Integer, Integer> progressListener)
throws PathfindingException
{
if(waypoints.size() < 2)
throw new IllegalArgumentException("Waypoints list must contain at least 2 locations.");
List<Location> fullPathPoints = new ArrayList<>();
AStarPathfinder aStar = new AStarPathfinder(maxIterations, allowDiagonalMovement);
AStarPathfinder aStar = new AStarPathfinder(maxIterations, allowDiagonalMovement, entityHeight, entityWidth);
for(int i = 0; i < waypoints.size() - 1; i++)
{
Location start = waypoints.get(i);
@@ -75,12 +129,10 @@ public class PathfindingUtils
if(segment == null)
throw new PathfindingException("Could not find path between waypoint " + i + " and " + (i + 1));
if(!fullPathPoints.isEmpty() && !segment.isEmpty() && isAbove(segment.get(0), fullPathPoints.get(fullPathPoints.size() - 1)))
if(!fullPathPoints.isEmpty() && !segment.isEmpty())
segment.remove(0);
else if(!segment.isEmpty())
segment.set(0, segment.get(0).subtract(0, 1, 0));
fullPathPoints.addAll(segment);
fullPathPoints.addAll(segment.stream().map(Location::clone).toList());
if(progressListener != null)
progressListener.accept(i + 1, waypoints.size() - 1);
@@ -89,11 +141,6 @@ public class PathfindingUtils
return new Path(fullPathPoints, waypoints);
}
private static boolean isAbove(@NotNull Location l1, @NotNull Location l2)
{
return l1.getX() == l2.getX() && l1.getY() - 1 == l2.getY() && l1.getZ() == l2.getZ();
}
public static class PathfindingException extends Exception
{
public PathfindingException(String message)
@@ -1,10 +1,12 @@
package de.eisi05.npc.api.scheduler;
import de.eisi05.npc.api.NpcApi;
import de.eisi05.npc.api.enums.WalkingResult;
import de.eisi05.npc.api.events.NpcStopWalkingEvent;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.pathfinding.AStarPathfinder;
import de.eisi05.npc.api.pathfinding.Path;
import de.eisi05.npc.api.utils.Var;
import de.eisi05.npc.api.wrapper.objects.WrappedEntity;
import de.eisi05.npc.api.wrapper.packets.MoveEntityPacket;
import de.eisi05.npc.api.wrapper.packets.RotateHeadPacket;
@@ -34,27 +36,33 @@ public class PathTask extends BukkitRunnable
private static final double gravity = -0.08;
private static final double jumpVelocity = 0.5;
private static final double terminalVelocity = -0.5;
private static final double stepHeight = -0.5;
private static final double stepHeight = 0.55;
private final NPC npc;
private final double entityHeight;
private final double entityWidth;
private final Path path;
private final List<Location> pathPoints;
private final Player[] viewers;
private final Set<UUID> viewerIds = new HashSet<>();
private final boolean autoManageWalkingViewers;
private final WrappedEntity<?> serverEntity;
private final Consumer<WalkingResult> callback;
private final boolean withRotation;
// Settings
private final double speed;
private final boolean updateRealLocation;
private final Set<Block> openedDoors = new HashSet<>();
// State
private final Set<Block> openedDoors = new HashSet<>();
private final Vector previousMoveDir;
private boolean finished = false;
private int index = 0;
private Vector currentPos;
private Vector previousMoveDir;
private float previousPitch;
private float previousYaw;
private double verticalVelocity = 0.0;
private int viewerRefreshTicks = 0;
private boolean isWaitingForChunkLoad = false;
/**
* Private constructor used by the Builder pattern.
@@ -64,15 +72,28 @@ public class PathTask extends BukkitRunnable
private PathTask(@NotNull Builder builder)
{
this.npc = builder.npc;
double scale = npc.getOption(NpcOption.SCALE);
this.entityHeight = npc.entity.getBoundingBox().getYSize() * scale;
this.entityWidth = npc.entity.getBoundingBox().getXSize() * scale;
this.path = builder.path;
this.pathPoints = new ArrayList<>(builder.path.asLocations());
this.viewers = builder.viewers;
if(builder.viewers != null)
{
for(Player viewer : builder.viewers)
{
if(viewer != null)
this.viewerIds.add(viewer.getUniqueId());
}
}
this.autoManageWalkingViewers = builder.autoManageWalkingViewers;
this.callback = builder.callback;
this.withRotation = builder.withRotation;
this.speed = builder.speed;
this.updateRealLocation = builder.updateRealLocation;
this.currentPos = npc.getLocation().toVector();
this.previousPitch = npc.getLocation().getPitch();
this.previousYaw = npc.getLocation().getYaw();
this.previousMoveDir = npc.getLocation().getDirection();
this.serverEntity = npc.entity;
@@ -84,6 +105,39 @@ public class PathTask extends BukkitRunnable
@Override
public void run()
{
World world = npc.getLocation().getWorld();
if(world == null) return;
int currentChunkX = currentPos.getBlockX() >> 4;
int currentChunkZ = currentPos.getBlockZ() >> 4;
if (!world.isChunkLoaded(currentChunkX, currentChunkZ))
{
handleUnloadedChunk(world, currentChunkX, currentChunkZ);
return;
}
if (index < pathPoints.size())
{
Location next = pathPoints.get(index);
int nextChunkX = next.getBlockX() >> 4;
int nextChunkZ = next.getBlockZ() >> 4;
if (!world.isChunkLoaded(nextChunkX, nextChunkZ))
{
handleUnloadedChunk(world, nextChunkX, nextChunkZ);
return;
}
}
isWaitingForChunkLoad = false;
if(autoManageWalkingViewers && viewerRefreshTicks++ >= 10)
{
viewerRefreshTicks = 0;
npc.refreshWalkingViewers();
}
if(index >= pathPoints.size())
{
if(finishPath())
@@ -99,25 +153,85 @@ public class PathTask extends BukkitRunnable
return;
}
processDoors();
cleanupDoors();
int chunkX = currentPos.getBlockX() >> 4;
int chunkZ = currentPos.getBlockZ() >> 4;
boolean isChunkLoaded = world.isChunkLoaded(chunkX, chunkZ);
Vector movement = calculateHorizontalMovement(toTarget, target);
if(isChunkLoaded)
{
processDoors();
cleanupDoors();
if(movement.lengthSquared() < 1e-6 && index < pathPoints.size() && currentPos.equals(target))
return;
Vector movement = calculateHorizontalMovement(toTarget, target);
PhysicsResult physics = applyPhysics(movement, toTarget);
if(movement.lengthSquared() < 1e-6 && index < pathPoints.size() && currentPos.equals(target))
return;
movement.setY(physics.yChange);
PhysicsResult physics = applyPhysics(movement, toTarget);
currentPos.add(movement);
if(physics.skipHorizontal)
{
movement.setX(0);
movement.setZ(0);
}
else if(physics.horizontalSlowdown < 1.0)
movement.multiply(physics.horizontalSlowdown);
float[] rotation = calculateSmoothRotation();
float yaw = rotation[0];
float pitch = rotation[1];
movement.setY(physics.yChange);
sendMovePackets(movement, yaw, pitch, physics.isGrounded);
currentPos.add(movement);
float yaw, pitch;
if(withRotation)
{
float[] rotation = calculateSmoothRotation();
yaw = rotation[0];
pitch = rotation[1];
}
else
{
yaw = npc.getLocation().getYaw();
pitch = npc.getLocation().getPitch();
}
sendMovePackets(movement, yaw, pitch, physics.isGrounded);
}
else
{
double distanceToTarget = toTarget.length();
double moveDist = Math.min(speed, distanceToTarget);
if(distanceToTarget > 1e-6)
{
Vector movement = toTarget.normalize().multiply(moveDist);
currentPos.add(movement);
}
else
currentPos = target.clone();
if(withRotation)
{
float[] rotation = calculateSmoothRotation();
previousYaw = rotation[0];
previousPitch = rotation[1];
}
if(updateRealLocation)
npc.setLocation(currentPos.toLocation(world));
}
}
/**
* Handles the logic when an NPC encounters an unloaded chunk.
*/
private void handleUnloadedChunk(World world, int chunkX, int chunkZ)
{
if (NpcApi.config.loadChunksOnPath() && !isWaitingForChunkLoad)
{
isWaitingForChunkLoad = true;
world.getChunkAt(chunkX, chunkZ);
isWaitingForChunkLoad = false;
}
}
/**
@@ -245,9 +359,10 @@ public class PathTask extends BukkitRunnable
if(event.changeRealLocation())
{
Location loc = path.getWaypoints().isEmpty() ? pathPoints.get(pathPoints.size() - 1) : path.getWaypoints().get(path.getWaypoints().size() - 1);
npc.changeRealLocation(loc, viewers);
npc.changeRealLocation(loc, getViewers());
}
npc.clearWalkingTask(this);
cancel();
return true;
}
@@ -269,8 +384,8 @@ public class PathTask extends BukkitRunnable
TeleportEntityPacket teleport = new TeleportEntityPacket(serverEntity,
new TeleportEntityPacket.PositionMoveRotation(loc.toVector(), new Vector(0, 0, 0), loc.getYaw(), loc.getPitch()), Set.of(), true);
npc.sendNpcMovePackets(teleport, head, viewers);
npc.sendNpcBodyPackets(body, viewers);
npc.sendNpcMovePackets(teleport, head, getViewers());
npc.sendNpcBodyPackets(body, getViewers());
}
/**
@@ -281,7 +396,9 @@ public class PathTask extends BukkitRunnable
*/
private boolean hasReachedWaypoint(@NotNull Vector toTarget)
{
return toTarget.lengthSquared() < 0.04 && Math.abs(toTarget.getY()) < 0.2;
double horizontalDistSq = (toTarget.getX() * toTarget.getX()) + (toTarget.getZ() * toTarget.getZ());
double verticalDiff = Math.abs(toTarget.getY());
return horizontalDistSq <= 0.04 && verticalDiff < 0.5;
}
/**
@@ -323,7 +440,7 @@ public class PathTask extends BukkitRunnable
{
World world = npc.getLocation().getWorld();
if(world == null)
return new PhysicsResult(0, false);
return new PhysicsResult(0, false, false, 1.0);
double groundY = getGroundY(world, currentPos);
boolean onGround = currentPos.getY() <= groundY + 1e-5;
@@ -331,14 +448,42 @@ public class PathTask extends BukkitRunnable
if(onGround)
{
if(toTarget.getY() < 0 && Math.abs(toTarget.getY()) <= stepHeight)
{
Vector testPos = currentPos.clone().add(movement);
testPos.setY(currentPos.getY() + toTarget.getY());
if(isPositionValid(world, testPos))
{
yChange = toTarget.getY();
verticalVelocity = 0;
double slowdown = 0.5;
return new PhysicsResult(yChange, true, false, slowdown);
}
}
if(toTarget.getY() > 0 && toTarget.getY() <= stepHeight && movement.lengthSquared() > 1e-6)
{
Vector testPos = currentPos.clone().add(movement);
testPos.setY(currentPos.getY() + Math.min(toTarget.getY(), stepHeight));
if(!isPositionValid(world, testPos))
{
yChange = Math.min(toTarget.getY(), stepHeight);
verticalVelocity = 0;
return new PhysicsResult(yChange, true, true, 1.0);
}
yChange = Math.min(toTarget.getY(), stepHeight);
verticalVelocity = 0;
return new PhysicsResult(yChange, true);
return new PhysicsResult(yChange, true, false, 1.0);
}
else if(toTarget.getY() > 0.5)
{
Vector testPos = currentPos.clone().add(movement);
testPos.setY(currentPos.getY() + jumpVelocity);
if(!isPositionValid(world, testPos))
{
verticalVelocity = jumpVelocity;
return new PhysicsResult(jumpVelocity, false, true, 1.0);
}
verticalVelocity = jumpVelocity;
onGround = false;
}
@@ -347,7 +492,7 @@ public class PathTask extends BukkitRunnable
verticalVelocity = 0;
if(Math.abs(currentPos.getY() - groundY) > 1e-6)
currentPos.setY(groundY);
return new PhysicsResult(0, true);
return new PhysicsResult(0, true, false, 1.0);
}
}
@@ -358,7 +503,15 @@ public class PathTask extends BukkitRunnable
verticalVelocity = terminalVelocity;
yChange = verticalVelocity;
if(currentPos.getY() + yChange <= groundY)
Vector testPos = currentPos.clone().add(movement);
testPos.setY(currentPos.getY() + yChange);
if(!isPositionValid(world, testPos) && currentPos.getY() + yChange <= groundY + 0.1)
{
yChange = groundY - currentPos.getY();
verticalVelocity = 0;
onGround = true;
}
else if(currentPos.getY() + yChange <= groundY)
{
yChange = groundY - currentPos.getY();
verticalVelocity = 0;
@@ -366,15 +519,27 @@ public class PathTask extends BukkitRunnable
}
}
return new PhysicsResult(yChange, onGround);
return new PhysicsResult(yChange, onGround, false, 1.0);
}
/**
* Calculates the Y-coordinate of the ground at a given position.
* Checks if a position is valid (not inside a solid block).
*
* @param world The world to check in
* @param pos The position to check
* @return The Y-coordinate of the ground
* @return true if the position is valid, false otherwise
*/
private boolean isPositionValid(@NotNull World world, @NotNull Vector pos)
{
return AStarPathfinder.isPositionValid(world, pos.getX(), pos.getY(), pos.getZ(), entityHeight, entityWidth);
}
/**
* Calculates the feet Y-coordinate of the ground at a given position.
*
* @param world The world to check in
* @param pos The position to check
* @return The Y-coordinate where the NPC's feet should be
*/
private double getGroundY(@NotNull World world, @NotNull Vector pos)
{
@@ -382,24 +547,35 @@ public class PathTask extends BukkitRunnable
int bz = pos.getBlockZ();
int startY = pos.getBlockY();
for(int y = startY; y >= startY - 3; y--)
for(int y = startY; y >= startY - 4; y--)
{
Block block = world.getBlockAt(bx, y, bz);
if(block.getBlockData() instanceof Openable)
if(block.getBlockData() instanceof Openable || block.isLiquid())
continue;
if(Var.isCarpet(block.getType()) && Var.isCarpet(block.getRelative(BlockFace.UP).getType()))
return ++y;
Collection<BoundingBox> boxes = block.getCollisionShape().getBoundingBoxes();
if(boxes.isEmpty())
continue;
if(!block.getType().isSolid() || block.isPassable())
return y;
double lx = pos.getX() - bx;
double lz = pos.getZ() - bz;
OptionalDouble maxY = block.getCollisionShape().getBoundingBoxes().stream().mapToDouble(BoundingBox::getMaxY).max();
OptionalDouble minY = block.getCollisionShape().getBoundingBoxes().stream().mapToDouble(BoundingBox::getMinY).min();
double bestTop = -1.0;
if(minY.isPresent() && maxY.isPresent())
return y + minY.getAsDouble() + (maxY.getAsDouble() - minY.getAsDouble());
for(BoundingBox bb : boxes)
{
if(lx >= bb.getMinX() && lx <= bb.getMaxX() && lz >= bb.getMinZ() && lz <= bb.getMaxZ())
bestTop = Math.max(bestTop, bb.getMaxY());
}
if(bestTop < 0.0)
{
for(BoundingBox bb : boxes)
bestTop = Math.max(bestTop, bb.getMaxY());
}
return y + bestTop;
}
return world.getHighestBlockYAt(bx, bz);
@@ -424,7 +600,7 @@ public class PathTask extends BukkitRunnable
Vector horizontalLook = new Vector(lookDir.getX(), 0, lookDir.getZ());
if(horizontalLook.lengthSquared() < 1e-6)
horizontalLook = previousMoveDir.clone();
horizontalLook.copy(previousMoveDir.clone());
float targetYaw = (float) (Math.toDegrees(Math.atan2(horizontalLook.getZ(), horizontalLook.getX())) - 90);
targetYaw = normalizeAngle(targetYaw);
@@ -434,7 +610,7 @@ public class PathTask extends BukkitRunnable
float yaw = previousYaw + diff;
previousYaw = yaw;
previousMoveDir = horizontalLook;
previousMoveDir.copy(horizontalLook);
Vector targetVec = pathPoints.get(Math.min(index + 1, pathPoints.size() - 1)).toVector().subtract(currentPos);
double hLen = Math.sqrt(targetVec.getX() * targetVec.getX() + targetVec.getZ() * targetVec.getZ());
@@ -458,6 +634,100 @@ public class PathTask extends BukkitRunnable
return angle;
}
/**
* Checks whether this task allows automatic walking viewer management.
*
* @return true if viewers can be added automatically; false otherwise
*/
public boolean isAutoManageWalkingViewers()
{
return autoManageWalkingViewers;
}
/**
* Gets the NPC's current walking location.
*
* @return the current walking location
*/
public @NotNull Location getCurrentLocation()
{
World world = npc.getLocation().getWorld();
return currentPos.toLocation(world);
}
/**
* Adds a viewer to this path task and immediately syncs the NPC's current walking position.
*
* @param player the player to add
* @return true if the player was newly added; false if the player was already a viewer
*/
public boolean addViewer(@NotNull Player player)
{
if(finished)
return false;
if(!viewerIds.add(player.getUniqueId()))
return false;
sendCurrentPosition(player);
return true;
}
/**
* Checks whether the specified player is already attached to this path task.
*
* @param player the player to check
* @return true if the player is already a viewer of this walking task; false otherwise
*/
public boolean hasViewer(@NotNull Player player)
{
return viewerIds.contains(player.getUniqueId());
}
/**
* Removes a viewer from this path task.
*
* @param player the player to remove
*/
public void removeViewer(@NotNull Player player)
{
viewerIds.remove(player.getUniqueId());
}
/**
* Gets all online viewers currently attached to this path task.
*
* @return online viewers
*/
private Player @NotNull [] getViewers()
{
return viewerIds.stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toArray(Player[]::new);
}
/**
* Sends the NPC's current walking position and rotation to a viewer.
*
* @param player the viewer to sync
*/
private void sendCurrentPosition(@NotNull Player player)
{
if(serverEntity == null)
return;
RotateHeadPacket head = new RotateHeadPacket(serverEntity, (byte) (previousYaw * 256 / 360));
Vector currentVec = new Vector(currentPos.getX(), currentPos.getY(), currentPos.getZ());
TeleportEntityPacket teleport = new TeleportEntityPacket(serverEntity,
new TeleportEntityPacket.PositionMoveRotation(currentVec, new Vector(0, 0, 0), previousYaw, previousPitch), Set.of(), true);
npc.sendNpcMovePackets(teleport, head, player);
}
/**
* Sends movement and rotation packets to update the NPC's position for viewers.
*
@@ -471,11 +741,17 @@ public class PathTask extends BukkitRunnable
if(serverEntity == null)
return;
previousYaw = yaw;
previousPitch = pitch;
RotateHeadPacket head = new RotateHeadPacket(serverEntity, (byte) (yaw * 256 / 360));
TeleportEntityPacket teleport = new TeleportEntityPacket(serverEntity, new TeleportEntityPacket.PositionMoveRotation(currentPos, movement, yaw, pitch),
Set.of(), onGround);
npc.sendNpcMovePackets(teleport, head, viewers);
npc.sendNpcMovePackets(teleport, head, getViewers());
if(updateRealLocation)
npc.setLocation(currentPos.toLocation(npc.getLocation().getWorld()));
}
/**
@@ -507,9 +783,9 @@ public class PathTask extends BukkitRunnable
World world = path.getWaypoints().isEmpty() ? pathPoints.get(pathPoints.size() - 1).getWorld() :
path.getWaypoints().get(path.getWaypoints().size() - 1).getWorld();
Location loc = new Location(world, currentPos.getX(), currentPos.getY(), currentPos.getZ());
npc.changeRealLocation(loc, viewers);
npc.changeRealLocation(loc, getViewers());
}
npc.clearWalkingTask(this);
}
/**
@@ -525,10 +801,12 @@ public class PathTask extends BukkitRunnable
/**
* A record representing the result of physics calculations.
*
* @param yChange The vertical movement to apply
* @param isGrounded Whether the NPC is on the ground
* @param yChange The vertical movement to apply
* @param isGrounded Whether the NPC is on the ground
* @param skipHorizontal Whether to skip horizontal movement this tick
* @param horizontalSlowdown Factor to slow down horizontal movement (1.0 = normal, <1.0 = slower)
*/
private record PhysicsResult(double yChange, boolean isGrounded) {}
private record PhysicsResult(double yChange, boolean isGrounded, boolean skipHorizontal, double horizontalSlowdown) {}
// --- Builder Class ---
@@ -544,6 +822,8 @@ public class PathTask extends BukkitRunnable
private Consumer<WalkingResult> callback = null;
private double speed = 1.0;
private boolean updateRealLocation = false;
private boolean withRotation = true;
private boolean autoManageWalkingViewers = false;
/**
* Creates a new Builder for a PathTask.
@@ -569,6 +849,20 @@ public class PathTask extends BukkitRunnable
return this;
}
/**
* Sets whether this path task should allow automatic walking viewer management.
* <p>
* When enabled, external library listeners may add eligible players to this task while the NPC is already walking.
*
* @param autoManageWalkingViewers true to allow automatic walking viewer management, false otherwise
* @return This builder instance for method chaining
*/
public @NotNull Builder autoManageWalkingViewers(boolean autoManageWalkingViewers)
{
this.autoManageWalkingViewers = autoManageWalkingViewers;
return this;
}
/**
* Sets the movement speed of the NPC.
*
@@ -605,6 +899,18 @@ public class PathTask extends BukkitRunnable
return this;
}
/**
* Sets whether to include rotation packets in the movement.
*
* @param withRotation true to include rotation packets, false otherwise
* @return This builder instance for method chaining
*/
public @NotNull Builder withRotation(boolean withRotation)
{
this.withRotation = withRotation;
return this;
}
/**
* Builds and returns a new PathTask instance.
*
@@ -13,9 +13,8 @@ import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -25,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class Tasks
{
public static final Map<UUID, Map<UUID, String>> placeholderCache = new ConcurrentHashMap<>();
private static final List<CompletableFuture<?>> activeFutures = new ArrayList<>();
private static BukkitTask lookAtTask;
private static BukkitTask placeholderTask;
@@ -47,6 +47,13 @@ public class Tasks
if(placeholderTask != null && !placeholderTask.isCancelled())
placeholderTask.cancel();
synchronized(activeFutures)
{
for(CompletableFuture<?> future : activeFutures)
future.cancel(true);
activeFutures.clear();
}
}
/**
@@ -97,10 +104,6 @@ public class Tasks
for(NPC npc : npcs)
{
NpcSkin npcSkin = npc.getOption(NpcOption.SKIN);
if(npcSkin == null || npcSkin.isStatic() || npcSkin.getPlaceholder() == null || npc.getOption(NpcOption.USE_PLAYER_SKIN))
continue;
for(UUID viewerId : npc.getViewers())
{
if(viewerId == null)
@@ -110,6 +113,10 @@ public class Tasks
if(player == null)
continue;
NpcSkin npcSkin = npc.getOption(NpcOption.SKIN, player);
if(npcSkin == null || npcSkin.isStatic() || npcSkin.getPlaceholder() == null || npc.getOption(NpcOption.USE_PLAYER_SKIN, player))
continue;
updateSkin(player, npc, npcSkin);
}
}
@@ -151,4 +158,24 @@ public class Tasks
Bukkit.getScheduler().runTaskLater(NpcApi.plugin, () -> npc.updateSkin(player), 1)));
}
}
/**
* Tracks a CompletableFuture for cancellation on plugin disable.
*
* @param future the future to track
*/
public static void trackFuture(@NotNull CompletableFuture<?> future)
{
synchronized(activeFutures)
{
activeFutures.add(future);
}
future.whenComplete((result, error) ->
{
synchronized(activeFutures)
{
activeFutures.remove(future);
}
});
}
}
@@ -0,0 +1,16 @@
package de.eisi05.npc.api.utils;
import java.lang.annotation.*;
/**
* Indicates that a configuration option or field is strictly reserved for third-party API integration and is not actively utilized by the internal plugin
* logic.
* <p/>
* {@link de.eisi05.npc.api.objects.NpcConfig}
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface ApiOnly
{
}
@@ -0,0 +1,51 @@
package de.eisi05.npc.api.utils;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.jetbrains.annotations.NotNull;
import java.util.OptionalInt;
/**
* Utility class for location-related operations.
*/
public class LocationUtils
{
/**
* Finds a safe Y level on the ground for the given location.
* Searches up to 5 blocks above and below the starting Y level.
*
* @param loc The location to find a safe Y level for
* @return An OptionalInt containing the safe Y level, or empty if none found
*/
public static OptionalInt findSafeY(@NotNull Location loc)
{
World world = loc.getWorld();
if(world == null)
return OptionalInt.empty();
int startX = loc.getBlockX();
int startZ = loc.getBlockZ();
int startY = loc.getBlockY();
for(int y = startY; y <= startY + 5; y++)
{
Block block = world.getBlockAt(startX, y, startZ);
if(block.getType().isSolid() && !block.getRelative(BlockFace.UP).getType().isSolid() &&
!block.getRelative(BlockFace.UP).getRelative(BlockFace.UP).getType().isSolid())
return OptionalInt.of(y + 1);
}
for(int y = startY; y >= startY - 5; y--)
{
Block block = world.getBlockAt(startX, y, startZ);
if(block.getType().isSolid() && !block.getRelative(BlockFace.UP).getType().isSolid() &&
!block.getRelative(BlockFace.UP).getRelative(BlockFace.UP).getType().isSolid())
return OptionalInt.of(y + 1);
}
return OptionalInt.empty();
}
}
@@ -0,0 +1,181 @@
package de.eisi05.npc.api.utils;
import de.eisi05.npc.api.objects.NPC;
import de.eisi05.npc.api.objects.NpcOption;
import de.eisi05.npc.api.wrapper.objects.WrappedEntity;
import org.bukkit.FluidCollisionMode;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import org.bukkit.util.RayTraceResult;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
public class NpcHitboxUtil
{
/**
* Checks whether a player's line of sight intersects the Oriented Bounding Box (OBB) of an NPC using an optimized RaySlab intersection algorithm.
* <p>
* This method accounts for the NPC's custom scale, bounding box dimensions, and spatial orientation (yaw rotation). It supports specialized poses such as
* {@link org.bukkit.entity.Pose#SLEEPING}, adjusting the bounding box extents and center offset dynamically to match the modified client-side model.
* </p>
* <p>
* To prevent clipping exploits, the method executes a fast-fail line-of-sight raytrace via Bukkit's world physics engine up to the point of structural
* intersection, ensuring that solid blocks or other valid entities do not obstruct the interaction.
* </p>
*
* @param npc the {@link NPC} instance whose bounding box is being evaluated
* @param sleeping {@code true} if the NPC is currently rendered in a sleeping pose, triggering specialized horizontal bounding box transformations
* @param player the {@link Player} executing the interaction, used to determine ray origin, direction vector, and maximum interaction range attributes
* @return {@code true} if the player's ray safely intersects the NPC's bounding box within their maximum interaction range without being obstructed by
* blocks or other entities; {@code false} otherwise * @see org.bukkit.util.Vector
* @see org.bukkit.util.RayTraceResult
*/
public static boolean rayIntersectsNpc(@NotNull NPC npc, boolean sleeping, @NotNull Player player)
{
Location eyeLocation = player.getEyeLocation();
Vector origin = eyeLocation.toVector();
Vector dir = eyeLocation.getDirection();
double maxDistance;
try
{
maxDistance = player.getAttribute(Attribute.valueOf("GENERIC_ENTITY_INTERACTION_RANGE")).getValue();
}
catch(Exception e)
{
maxDistance = player.getGameMode() == GameMode.CREATIVE ? 5.0 : 3.0;
}
Location base = npc.getLocation();
float yaw = base.getYaw();
double yawRad = Math.toRadians(yaw);
double cos = Math.cos(yawRad);
double sin = Math.sin(yawRad);
WrappedEntity.BoundingBox bb = npc.entity.getDefaultBoundingBox();
double scale = npc.getOption(NpcOption.SCALE, player);
double width, height, length;
Vector centerOffset = new Vector(0, 0, 0);
if(sleeping)
{
width = bb.getXSize() * scale;
height = bb.getZSize() * scale;
length = bb.getYSize() * scale;
// Inlined forward vector rotation to save allocations
Vector forward = new Vector(-sin, 0, cos).normalize();
centerOffset.copy(forward).multiply(-length / 2.0);
centerOffset.setY(height / 2.0);
}
else
{
width = bb.getXSize() * scale;
height = bb.getYSize() * scale;
length = bb.getZSize() * scale;
centerOffset.setY(height / 2.0);
}
Vector center = base.toVector().add(centerOffset);
Vector oc = origin.clone().subtract(center);
// Inverse rotation to bring ray into local OBB space
double ox = oc.getX() * cos + oc.getZ() * sin;
double oz = -oc.getX() * sin + oc.getZ() * cos;
double oy = oc.getY();
double dx = dir.getX() * cos + dir.getZ() * sin;
double dz = -dir.getX() * sin + dir.getZ() * cos;
double dy = dir.getY();
double minX = -width / 2.0, maxX = width / 2.0;
double minY = -height / 2.0, maxY = height / 2.0;
double minZ = -length / 2.0, maxZ = length / 2.0;
double tMin = 0.0;
double tMax = maxDistance;
// X Slab
if(Math.abs(dx) < 1e-8)
{
if(ox < minX || ox > maxX)
return false;
}
else
{
double invDx = 1.0 / dx;
double t1 = (minX - ox) * invDx;
double t2 = (maxX - ox) * invDx;
if(t1 > t2)
{
double tmp = t1;
t1 = t2;
t2 = tmp;
}
tMin = Math.max(tMin, t1);
tMax = Math.min(tMax, t2);
if(tMin > tMax)
return false;
}
// Y Slab
if(Math.abs(dy) < 1e-8)
{
if(oy < minY || oy > maxY)
return false;
}
else
{
double invDy = 1.0 / dy;
double t1 = (minY - oy) * invDy;
double t2 = (maxY - oy) * invDy;
if(t1 > t2)
{
double tmp = t1;
t1 = t2;
t2 = tmp;
}
tMin = Math.max(tMin, t1);
tMax = Math.min(tMax, t2);
if(tMin > tMax)
return false;
}
// Z Slab
if(Math.abs(dz) < 1e-8)
{
if(oz < minZ || oz > maxZ)
return false;
}
else
{
double invDz = 1.0 / dz;
double t1 = (minZ - oz) * invDz;
double t2 = (maxZ - oz) * invDz;
if(t1 > t2)
{
double tmp = t1;
t1 = t2;
t2 = tmp;
}
tMin = Math.max(tMin, t1);
tMax = Math.min(tMax, t2);
if(tMin > tMax)
return false;
}
RayTraceResult blockHit = eyeLocation.getWorld().rayTraceBlocks(eyeLocation, dir, tMin, FluidCollisionMode.NEVER, true);
if(blockHit != null && blockHit.getHitBlock() != null)
return false;
RayTraceResult entityHit = eyeLocation.getWorld().rayTraceEntities(eyeLocation, dir, tMin, 0.1,
(entity) -> !entity.equals(player) && entity.isValid());
if(entityHit != null && entityHit.getHitEntity() != null)
return false;
return true;
}
}
@@ -8,17 +8,17 @@ import java.util.ArrayList;
import java.util.List;
/**
* The {@link ObjectSaver} class provides utility methods for serializing and deserializing
* Java objects and lists of objects to and from a file. It uses standard Java
* object serialization.
* The {@link ObjectSaver} class provides utility methods for serializing and deserializing Java objects and lists of objects to and from a file. It uses
* standard Java object serialization.
*/
public class ObjectSaver
{
private final static ObjectInputFilter objectInputFilter = filterInfo -> ObjectInputFilter.Status.ALLOWED;
private final File file;
/**
* Constructs a new {@code ObjectSaver} instance, creating a {@link File} object
* from the given file path string. It ensures the parent directories exist
* Constructs a new {@code ObjectSaver} instance, creating a {@link File} object from the given file path string. It ensures the parent directories exist
* and creates the file if it doesn't already exist.
*
* @param file The path to the file where objects will be saved/loaded. Must not be {@code null}.
@@ -30,9 +30,8 @@ public class ObjectSaver
}
/**
* Constructs a new {@code ObjectSaver} instance with the specified {@link File} object.
* It ensures the parent directories of the file exist and creates the file itself
* if it does not already exist.
* Constructs a new {@code ObjectSaver} instance with the specified {@link File} object. It ensures the parent directories of the file exist and creates the
* file itself if it does not already exist.
*
* @param file The {@link File} object where objects will be saved/loaded. Must not be {@code null}.
* @throws RuntimeException If an {@link IOException} occurs while creating the file.
@@ -46,7 +45,8 @@ public class ObjectSaver
try
{
file.createNewFile();
} catch(IOException e)
}
catch(IOException e)
{
throw new RuntimeException(e);
}
@@ -54,8 +54,8 @@ public class ObjectSaver
}
/**
* Writes a single serializable object to the file, appending to the file if it already exists.
* This is a convenience method that calls {@link #write(Serializable, boolean)} with {@code append} set to {@code true}.
* Writes a single serializable object to the file, appending to the file if it already exists. This is a convenience method that calls
* {@link #write(Serializable, boolean)} with {@code append} set to {@code true}.
*
* @param object The object to write to the file. Must implement {@link Serializable}.
* @param <T> The type of the object, which must extend {@link Serializable}.
@@ -63,15 +63,14 @@ public class ObjectSaver
*/
public <T extends Serializable> void write(T object) throws IOException
{
this.write(object, true);
this.write(object, false);
}
/**
* Writes a single serializable object to the file.
*
* @param object The object to write to the file. Must implement {@link Serializable}.
* @param append If {@code true}, the object will be appended to the end of the file.
* If {@code false}, the file will be truncated (its contents deleted)
* @param append If {@code true}, the object will be appended to the end of the file. If {@code false}, the file will be truncated (its contents deleted)
* before writing the new object.
* @param <T> The type of the object, which must extend {@link Serializable}.
* @throws IOException If an I/O error occurs during writing.
@@ -85,8 +84,8 @@ public class ObjectSaver
}
/**
* Writes a list of serializable objects to the file, appending to the file if it already exists.
* This is a convenience method that calls {@link #writeList(List, boolean)} with {@code append} set to {@code true}.
* Writes a list of serializable objects to the file, appending to the file if it already exists. This is a convenience method that calls
* {@link #writeList(List, boolean)} with {@code append} set to {@code true}.
*
* @param object The list of objects to write to the file. Each object in the list must implement {@link Serializable}. Must not be {@code null}.
* @param <T> The type of the objects in the list, which must extend {@link Serializable}.
@@ -94,15 +93,14 @@ public class ObjectSaver
*/
public <T extends Serializable> void writeList(@NotNull List<T> object) throws IOException
{
this.writeList(object, true);
this.writeList(object, false);
}
/**
* Writes a list of serializable objects to the file.
*
* @param object The list of objects to write to the file. Each object in the list must implement {@link Serializable}. Must not be {@code null}.
* @param append If {@code true}, the list will be appended to the end of the file.
* If {@code false}, the file will be truncated (its contents deleted)
* @param append If {@code true}, the list will be appended to the end of the file. If {@code false}, the file will be truncated (its contents deleted)
* before writing the new list.
* @param <T> The type of the objects in the list, which must extend {@link Serializable}.
* @throws IOException If an I/O error occurs during writing.
@@ -119,8 +117,7 @@ public class ObjectSaver
* Reads a single serializable object from the file.
*
* @param <T> The expected type of the object, which must extend {@link Serializable}.
* @return The deserialized object, or {@code null} if an error occurs during reading
* (e.g., file not found, EOF, class not found, or I/O error).
* @return The deserialized object, or {@code null} if an error occurs during reading (e.g., file not found, EOF, class not found, or I/O error).
*/
@SuppressWarnings("unchecked")
public <T extends Serializable> @Nullable T read()
@@ -129,10 +126,12 @@ public class ObjectSaver
{
FileInputStream fileIn = new FileInputStream(this.file);
ObjectInputStream objectOut = new ObjectInputStream(fileIn);
objectOut.setObjectInputFilter(objectInputFilter);
Object object = objectOut.readObject();
objectOut.close();
return (T) object;
} catch(IOException | ClassNotFoundException e)
}
catch(IOException | ClassNotFoundException e)
{
throw new RuntimeException(e);
}
@@ -142,8 +141,8 @@ public class ObjectSaver
* Reads a list of serializable objects from the file.
*
* @param <T> The expected type of the objects in the list, which must extend {@link Serializable}.
* @return The deserialized list of objects. Returns an empty {@link ArrayList} if an error occurs
* during reading (e.g., file not found, EOF, class not found, or I/O error), or if the file is empty.
* @return The deserialized list of objects. Returns an empty {@link ArrayList} if an error occurs during reading (e.g., file not found, EOF, class not
* found, or I/O error), or if the file is empty.
*/
@SuppressWarnings("unchecked")
public <T extends Serializable> @NotNull List<T> readList()
@@ -152,10 +151,12 @@ public class ObjectSaver
{
FileInputStream fileIn = new FileInputStream(this.file);
ObjectInputStream objectOut = new ObjectInputStream(fileIn);
objectOut.setObjectInputFilter(objectInputFilter);
Object object = objectOut.readObject();
objectOut.close();
return (List<T>) object;
} catch(Exception var4)
}
catch(Exception var4)
{
return new ArrayList();
}
@@ -116,8 +116,7 @@ public class PacketReader
if(npc == null)
return;
Bukkit.getScheduler().scheduleSyncDelayedTask(NpcApi.plugin,
() -> Bukkit.getPluginManager().callEvent(new NpcInteractEvent(player, npc, ClickActionType.LEFT)), 0);
callNpc(player, npc, ClickActionType.LEFT);
cancelUseUntilTick.put(player.getUniqueId(), currentTick + 10);
return;
}
@@ -138,8 +137,7 @@ public class PacketReader
if(useEntityPacketWrapper.usingSecondaryAction() || useEntityPacketWrapper.getHand() == InteractionHand.OFF_HAND)
return;
Bukkit.getScheduler().scheduleSyncDelayedTask(NpcApi.plugin,
() -> Bukkit.getPluginManager().callEvent(new NpcInteractEvent(player, npc, ClickActionType.RIGHT)), 0);
callNpc(player, npc, ClickActionType.RIGHT);
cancelUseUntilTick.put(player.getUniqueId(), currentTick + 10);
return;
}
@@ -147,18 +145,28 @@ public class PacketReader
UseEntityPacketWrapper.Action action = useEntityPacketWrapper.getAction();
if(action.getActionType() == UseEntityPacketWrapper.ActionType.ATTACK)
{
Bukkit.getScheduler().scheduleSyncDelayedTask(NpcApi.plugin,
() -> Bukkit.getPluginManager().callEvent(new NpcInteractEvent(player, npc, ClickActionType.LEFT)), 0);
callNpc(player, npc, ClickActionType.LEFT);
cancelUseUntilTick.put(player.getUniqueId(), currentTick + 10);
}
else if(action.getActionType() == UseEntityPacketWrapper.ActionType.INTERACT && action.getHand() == InteractionHand.MAIN_HAND)
{
Bukkit.getScheduler().scheduleSyncDelayedTask(NpcApi.plugin,
() -> Bukkit.getPluginManager().callEvent(new NpcInteractEvent(player, npc, ClickActionType.RIGHT)), 0);
callNpc(player, npc, ClickActionType.RIGHT);
cancelUseUntilTick.put(player.getUniqueId(), currentTick + 10);
}
}
/**
* Calls the {@link NpcInteractEvent} for the specified player and NPC.
*
* @param player The {@link Player} who interacted with the NPC. Must not be {@code null}.
* @param npc The {@link NPC} that was interacted with. Must not be {@code null}.
* @param type The type of interaction (left or right click). Must not be {@code null}.
*/
public static void callNpc(@NotNull Player player, @NotNull NPC npc, @NotNull ClickActionType type)
{
Bukkit.getScheduler().runTask(NpcApi.plugin, () -> Bukkit.getPluginManager().callEvent(new NpcInteractEvent(player, npc, type)));
}
/**
* Uninjects the custom {@link ChannelDuplexHandler} from the specified player's Netty pipeline. This stops the interception of packets for that player.
*
@@ -1,24 +1,27 @@
package de.eisi05.npc.api.utils;
import com.google.common.primitives.Primitives;
import de.eisi05.npc.api.wrapper.Wrapper;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Utility class providing methods for reflective operations such as loading classes,
* invoking methods, and accessing fields dynamically at runtime.
* Utility class providing methods for reflective operations such as loading classes, invoking methods, and accessing fields dynamically at runtime.
*/
@SuppressWarnings("unchecked")
public class Reflections
{
private static final ConcurrentHashMap<MethodKey, Method> METHOD_CACHE = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<FieldKey, Field> FIELD_CACHE = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<ConstructorKey, Constructor<?>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Class<?>> CLASS_CACHE = new ConcurrentHashMap<>();
/**
* Loads a class by its fully qualified name.
*
@@ -30,8 +33,16 @@ public class Reflections
{
try
{
return Optional.of((Class<T>) Class.forName(path));
} catch(ClassNotFoundException e)
Class<?> cached = CLASS_CACHE.get(path);
if(cached != null)
return Optional.of((Class<T>) cached);
Class<?> clazz = Class.forName(path);
CLASS_CACHE.put(path, clazz);
return Optional.of((Class<T>) clazz);
}
catch(ClassNotFoundException e)
{
return Optional.empty();
}
@@ -52,46 +63,42 @@ public class Reflections
public static <T> @NotNull Optional<T> getInstance(@NotNull Class<T> clazz, @Nullable Object... args)
{
final Object[] newArgs = Wrapper.checkArgs(args);
try
{
Class<?>[] argTypes = Arrays.stream(newArgs)
.map(Object::getClass)
.toArray(Class[]::new);
Class<?>[] argTypes = args == null ? new Class<?>[0] : Arrays.stream(args)
.map(o -> o == null ? Object.class : o.getClass())
.toArray(Class<?>[]::new);
for(Constructor<?> ctor : clazz.getDeclaredConstructors())
ConstructorKey key = new ConstructorKey(clazz, argTypes);
Constructor<?> ctor = CONSTRUCTOR_CACHE.get(key);
if(ctor == null)
{
Class<?>[] paramTypes = ctor.getParameterTypes();
if(paramTypes.length != argTypes.length)
continue;
boolean match = true;
for(int i = 0; i < paramTypes.length; i++)
for(Constructor<?> constructor : clazz.getDeclaredConstructors())
{
if(!paramTypes[i].isAssignableFrom(argTypes[i]))
if(isCompatible(argTypes, constructor.getParameterTypes(), constructor.isVarArgs()))
{
match = false;
ctor = constructor;
ctor.setAccessible(true);
CONSTRUCTOR_CACHE.put(key, ctor);
break;
}
}
if(match)
{
ctor.setAccessible(true);
return Optional.of((T) ctor.newInstance(newArgs));
}
}
return Optional.empty();
} catch(Exception e)
if(ctor == null)
throw new NoSuchMethodException("No compatible constructor found in class " + clazz.getName() + " for arguments " + Arrays.toString(argTypes));
return Optional.of((T) ctor.newInstance(args));
}
catch(Exception e)
{
return Optional.empty();
}
}
/**
* Finds a declared method in the specified class that matches the given name and parameter types.
* Finds a declared method in the specified class that matches the given name and parameter types. Uses caching to avoid repeated reflection overhead.
*
* @param clazz the class to search in
* @param name the name of the method
@@ -101,11 +108,16 @@ public class Reflections
*/
private static @NotNull Method findMethod(@NotNull Class<?> clazz, @NotNull String name, @Nullable Object[] args) throws NoSuchMethodException
{
Class<?> current = clazz;
Class<?>[] argTypes = Arrays.stream(args)
.map(Object::getClass)
.toArray(Class<?>[]::new);
Class<?>[] argTypes = args == null ? new Class<?>[0] : Arrays.stream(args)
.map(o -> o == null ? Object.class : o.getClass())
.toArray(Class<?>[]::new);
MethodKey key = new MethodKey(clazz, name, argTypes);
Method cached = METHOD_CACHE.get(key);
if(cached != null)
return cached;
Class<?> current = clazz;
while(current != null)
{
for(Method method : current.getDeclaredMethods())
@@ -119,6 +131,7 @@ public class Reflections
if(isCompatible(argTypes, paramTypes, isVarArgs))
{
method.setAccessible(true);
METHOD_CACHE.put(key, method);
return method;
}
}
@@ -131,8 +144,7 @@ public class Reflections
/**
* Checks if a given set of argument types is compatible with the parameter types of a method.
* <p>
* This method supports both regular and varargs methods. It also handles primitive-to-wrapper
* type conversions (e.g., int to Integer).
* This method supports both regular and varargs methods. It also handles primitive-to-wrapper type conversions (e.g., int to Integer).
*
* @param args The types of the provided arguments.
* @param params The types of the method's parameters.
@@ -181,13 +193,13 @@ public class Reflections
*/
public static <V> @NotNull ReflectionChain<V> invokeMethod(@NotNull Object object, @NotNull String methodName, @Nullable Object... args)
{
args = Wrapper.checkArgs(args);
try
{
Method method = findMethod(object.getClass(), methodName, args);
method.setAccessible(true);
return new ReflectionChain<>((V) method.invoke(object, args));
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -204,21 +216,44 @@ public class Reflections
*/
public static <V> @NotNull ReflectionChain<V> invokeStaticMethod(@NotNull String classPath, @NotNull String methodName, @Nullable Object... args)
{
args = Wrapper.checkArgs(args);
try
{
Class<?> clazz = Class.forName(classPath);
Method method = findMethod(clazz, methodName, args);
method.setAccessible(true);
return new ReflectionChain<>((V) method.invoke(null, args));
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
}
/**
* Finds a declared field in the specified class by its name and makes it accessible.
* Invokes a static method of a class given its fully qualified name.
*
* @param clazz the class of the method
* @param methodName name of the static method
* @param args method arguments
* @param <V> return type of the method
* @return a ReflectionChain wrapping the method's return value
*/
public static <V> @NotNull ReflectionChain<V> invokeStaticMethod(@NotNull Class<?> clazz, @NotNull String methodName, @Nullable Object... args)
{
try
{
Method method = findMethod(clazz, methodName, args);
method.setAccessible(true);
return new ReflectionChain<>((V) method.invoke(null, args));
}
catch(Exception e)
{
throw new RuntimeException(e);
}
}
/**
* Finds a declared field in the specified class by its name and makes it accessible. Uses caching to avoid repeated reflection overhead.
*
* @param clazz the class to search in
* @param fieldName the name of the field
@@ -227,14 +262,21 @@ public class Reflections
*/
private static @NotNull Field findField(@NotNull Class<?> clazz, @NotNull String fieldName) throws NoSuchFieldException
{
FieldKey key = new FieldKey(clazz, fieldName);
Field cached = FIELD_CACHE.get(key);
if(cached != null)
return cached;
while(clazz != null)
{
try
{
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
FIELD_CACHE.put(key, field);
return field;
} catch(NoSuchFieldException ignored)
}
catch(NoSuchFieldException ignored)
{
clazz = clazz.getSuperclass();
}
@@ -256,7 +298,8 @@ public class Reflections
{
Field field = findField(object.getClass(), fieldName);
return new ReflectionChain<>((T) field.get(object));
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -277,7 +320,8 @@ public class Reflections
{
Field field = findField(clazz, fieldName);
return (V) field.get(null);
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -298,7 +342,8 @@ public class Reflections
Class<?> clazz = Class.forName(classPath);
Field field = findField(clazz, fieldName);
return (T) field.get(null);
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -317,7 +362,8 @@ public class Reflections
{
Field field = findField(object.getClass(), fieldName);
field.set(object, value);
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -337,12 +383,77 @@ public class Reflections
Class<?> clazz = Class.forName(classPath);
Field field = findField(clazz, fieldName);
field.set(null, value);
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
}
private record MethodKey(Class<?> clazz, String methodName, Class<?>[] paramTypes)
{
@Override
public boolean equals(Object o)
{
if(this == o)
return true;
if(!(o instanceof MethodKey methodKey))
return false;
return clazz.equals(methodKey.clazz) && methodName.equals(methodKey.methodName) && Arrays.equals(paramTypes, methodKey.paramTypes);
}
@Override
public int hashCode()
{
int result = clazz.hashCode();
result = 31 * result + methodName.hashCode();
result = 31 * result + Arrays.hashCode(paramTypes);
return result;
}
}
private record FieldKey(Class<?> clazz, String fieldName)
{
@Override
public boolean equals(Object o)
{
if(this == o)
return true;
if(!(o instanceof FieldKey fieldKey))
return false;
return clazz.equals(fieldKey.clazz) && fieldName.equals(fieldKey.fieldName);
}
@Override
public int hashCode()
{
int result = clazz.hashCode();
result = 31 * result + fieldName.hashCode();
return result;
}
}
private record ConstructorKey(Class<?> clazz, Class<?>[] paramTypes)
{
@Override
public boolean equals(Object o)
{
if(this == o)
return true;
if(!(o instanceof ConstructorKey constructorKey))
return false;
return clazz.equals(constructorKey.clazz) && Arrays.equals(paramTypes, constructorKey.paramTypes);
}
@Override
public int hashCode()
{
int result = clazz.hashCode();
result = 31 * result + Arrays.hashCode(paramTypes);
return result;
}
}
/**
* Helper class to chain reflection calls on the result of previous reflective operations.
*
@@ -374,13 +485,13 @@ public class Reflections
if(value == null)
return new ReflectionChain<>(null);
args = Wrapper.checkArgs(args);
try
{
Method method = findMethod(value.getClass(), methodName, args);
method.setAccessible(true);
return new ReflectionChain<>((V) method.invoke(value, args));
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -400,7 +511,8 @@ public class Reflections
{
Field field = findField(value.getClass(), fieldName);
return new ReflectionChain<>((V) field.get(value));
} catch(Exception e)
}
catch(Exception e)
{
throw new RuntimeException(e);
}
@@ -0,0 +1,15 @@
package de.eisi05.npc.api.utils;
import java.io.Serializable;
import java.util.function.BiPredicate;
/**
* A serializable version of {@link BiPredicate} that can be used in contexts where the predicate needs to be serialized.
*
* @param <T> the type of the first argument to the predicate
* @param <U> the type of the second argument to the predicate
*/
@FunctionalInterface
public interface SerializableBiPredicate<T, U> extends BiPredicate<T, U>, Serializable
{
}
@@ -0,0 +1,17 @@
package de.eisi05.npc.api.utils;
import java.io.Serializable;
import java.util.function.Consumer;
/**
* A functional interface that combines {@link Consumer} and {@link Serializable}.
* <p>
* This interface allows lambda expressions to be used as consumers while also being serializable, which is useful for scenarios where consumer logic needs to
* be persisted or transmitted across different contexts.
*
* @param <T> the type of the input to the operation
*/
@FunctionalInterface
public interface SerializableConsumer<T> extends Consumer<T>, Serializable
{
}
@@ -0,0 +1,14 @@
package de.eisi05.npc.api.utils;
import java.io.Serializable;
import java.util.function.Predicate;
/**
* A serializable version of {@link Predicate} that can be used in contexts where the predicate needs to be serialized.
*
* @param <T> the type of the first argument to the predicate
*/
@FunctionalInterface
public interface SerializablePredicate<T> extends Predicate<T>, Serializable
{
}
@@ -2,7 +2,6 @@ package de.eisi05.npc.api.utils;
import de.eisi05.npc.api.wrapper.objects.WrappedComponent;
import de.eisi05.npc.api.wrapper.objects.WrappedEntitySnapshot;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
@@ -219,9 +218,4 @@ public class Var
return flags;
}
public static boolean isCarpet(@NotNull Material material)
{
return material.name().contains("CARPET");
}
}
@@ -20,10 +20,12 @@ import java.util.Arrays;
public class WrappedEntity<T extends Entity> extends Wrapper
{
public String data = "{}";
private final BoundingBox defaultBoundingBox;
WrappedEntity(Object handle)
{
super(handle);
defaultBoundingBox = getBoundingBox();
}
public static <T extends Entity, V extends WrappedEntity<T>> @NotNull V fromEntity(@NotNull T entity, @NotNull Class<V> clazz)
@@ -156,6 +158,11 @@ public class WrappedEntity<T extends Entity> extends Wrapper
return new BoundingBox(invokeWrappedMethod());
}
public @NotNull BoundingBox getDefaultBoundingBox()
{
return defaultBoundingBox;
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "setId")
@Mapping(range = @Mapping.Range(from = Versions.V1_21_9, to = Versions.V1_21_11), path = "e")
public void setId(int id)
@@ -253,12 +260,26 @@ public class WrappedEntity<T extends Entity> extends Wrapper
super(handle);
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getXsize")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_11), path = "b")
public double getXSize()
{
return invokeWrappedMethod();
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getYsize")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_11), path = "c")
public double getYSize()
{
return invokeWrappedMethod();
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getZsize")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_11), path = "d")
public double getZSize()
{
return invokeWrappedMethod();
}
}
public abstract static class WrappedNameTag<T extends Entity> extends WrappedEntity<T>
@@ -29,6 +29,7 @@ public class WrappedEntityData extends Wrapper
public <T> void set(@NotNull EntityDataAccessor<T> accessor, @Nullable T value)
{
invokeWrappedMethod(accessor, value);
markDirty(accessor);
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "get")
@@ -46,6 +47,12 @@ public class WrappedEntityData extends Wrapper
return invokeWrappedMethod();
}
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V26_1), path = "markDirty")
public <T> void markDirty(@NotNull EntityDataAccessor<T> accessor)
{
invokeWrappedMethod(accessor);
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getNonDefaultValues")
@Mapping(range = @Mapping.Range(from = Versions.V1_18, to = Versions.V1_21_11), path = "c")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_17), path = "getAll")
@@ -7,6 +7,7 @@ import de.eisi05.npc.api.wrapper.Wrapper;
import de.eisi05.npc.api.wrapper.enums.ChatFormat;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.HashMap;
@@ -80,6 +81,21 @@ public class WrappedPlayerTeam extends Wrapper
return teams.getOrDefault(player.getUniqueId(), new HashMap<>()).containsKey(name);
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getNameTagVisibility")
@Mapping(range = @Mapping.Range(from = Versions.V1_21_5, to = Versions.V1_21_11), path = "k")
@Mapping(range = @Mapping.Range(from = Versions.V1_18, to = Versions.V1_21_4), path = "j")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_17), path = "getNameTagVisibility")
public @Nullable Visibility getNameTagVisibility()
{
Object enumHandle = getWrappedFieldValue();
for(Visibility visibility : Visibility.values())
{
if(visibility.getHandle().equals(enumHandle))
return visibility;
}
return null;
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "setNameTagVisibility")
@Mapping(range = @Mapping.Range(from = Versions.V1_18, to = Versions.V1_21_11), path = "a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_17), path = "setNameTagVisibility")
@@ -104,6 +120,15 @@ public class WrappedPlayerTeam extends Wrapper
invokeWrappedMethod(canSeeFriendlyInvisible);
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "canSeeFriendlyInvisibles")
@Mapping(range = @Mapping.Range(from = Versions.V1_21_5, to = Versions.V1_21_11), path = "j")
@Mapping(range = @Mapping.Range(from = Versions.V1_18, to = Versions.V1_21_4), path = "i")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_17), path = "canSeeFriendlyInvisibles")
public boolean canSeeFriendlyInvisible()
{
return invokeWrappedMethod();
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getPlayers")
@Mapping(range = @Mapping.Range(from = Versions.V1_21_5, to = Versions.V1_21_11), path = "h")
@Mapping(range = @Mapping.Range(from = Versions.V1_18, to = Versions.V1_21_4), path = "g")
@@ -139,7 +164,8 @@ public class WrappedPlayerTeam extends Wrapper
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "net.minecraft.world.scores.Team$Visibility")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_11), path = "net.minecraft.world.scores.ScoreboardTeamBase$EnumNameTagVisibility")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_11), path = "net.minecraft.world.scores" +
".ScoreboardTeamBase$EnumNameTagVisibility")
public enum Visibility implements EnumWrapper
{
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "ALWAYS")
@@ -206,7 +206,7 @@ public class WrappedServerPlayer extends WrappedEntity<Player>
return new WrappedCommandSourceStack(invokeWrappedMethod());
}
@Mapping(range = @Mapping.Range(from = Versions.V1_21_11, to = Versions.V1_21_11), path = "stopUsingItem")
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "stopUsingItem")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_11), path = "gf")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_9), path = "fU")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_6), path = "fM")
@@ -248,6 +248,26 @@ public class WrappedServerPlayer extends WrappedEntity<Player>
Reflections.invokeMethod(nmsLevel, getPath(), getHandle(), RemovalReason.DISCARDED.getHandle());
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "getAdvancements-stopListening")
@Mapping(range = @Mapping.Range(from = Versions.V1_21_9, to = Versions.V1_21_11), path = "U-a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_6), path = "S-a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_5), path = "R-a")
@Mapping(range = @Mapping.Range(from = Versions.V1_21_2, to = Versions.V1_21_4), path = "S-a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21), path = "R-a")
@Mapping(range = @Mapping.Range(from = Versions.V1_20_4, to = Versions.V1_20_6), path = "Q-a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_20_2), path = "O-a")
@Mapping(range = @Mapping.Range(from = Versions.V1_19_4, to = Versions.V1_20), path = "M-a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_19_3), path = "N-a")
@Mapping(range = @Mapping.Range(from = Versions.V1_18, to = Versions.V1_19_1), path = "M-a")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_17), path = "getAdvancementData-a")
public void stopAdvancementListening()
{
String path = getPath();
String[] paths = path.split("-");
Reflections.invokeMethod(getHandle(), paths[0]).thanInvoke(paths[1]);
}
public @NotNull String getName()
{
return (String) Reflections.getField(getGameProfile(), "name").get();
@@ -17,15 +17,16 @@ public class WrappedVec2F extends Wrapper
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "x")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_11), path = "j")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_9), path = "i")
public float getYaw()
{
return getWrappedFieldValue();
}
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_11), path = "y")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_9), path = "j")
public float getPitch()
{
return getWrappedFieldValue();
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "y")
@Mapping(fixed = @Mapping.Fixed(Versions.V1_21_11), path = "k")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_9), path = "j")
public float getYaw()
{
return getWrappedFieldValue();
}
}
@@ -33,7 +33,7 @@ public class SetEntityDataPacket extends PacketWrapper
return new SetEntityDataPacket(id, data, true);
else
{
List<?> dataList = data.getAll();
List<?> dataList = data.packDirty();
return new SetEntityDataPacket(id, dataList == null ? new ArrayList<>() : dataList);
}
}
@@ -11,8 +11,8 @@ import org.jetbrains.annotations.Nullable;
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_21_11), path = "net.minecraft.network.protocol.game.PacketPlayInUseEntity")
public class UseEntityPacketWrapper extends PacketWrapper.PacketHolder
{
@Mapping(range = @Mapping.Range(from = Versions.V1_21, to = Versions.V1_21_11), path = "e")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_20_6), path = "d")
@Mapping(range = @Mapping.Range(from = Versions.V1_20_6, to = Versions.V1_21_11), path = "e")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_20_4), path = "d")
public final static Action ATTACK_ACTION = new Action(getStaticWrappedFieldValue("ATTACK_ACTION").orElse(null));
protected UseEntityPacketWrapper(Object handle)
@@ -21,8 +21,8 @@ public class UseEntityPacketWrapper extends PacketWrapper.PacketHolder
}
@Mapping(range = @Mapping.Range(from = Versions.V26_1, to = Versions.V26_1), path = "entityId")
@Mapping(range = @Mapping.Range(from = Versions.V1_21, to = Versions.V1_21_11), path = "b")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_20_6), path = "a")
@Mapping(range = @Mapping.Range(from = Versions.V1_20_6, to = Versions.V1_21_11), path = "b")
@Mapping(range = @Mapping.Range(from = Versions.V1_17, to = Versions.V1_20_4), path = "a")
public int getId()
{
return getWrappedFieldValue();