├── settings.gradle ├── img └── zombie-player-example.gif ├── .gitignore ├── src ├── test │ ├── resources │ │ └── plugin.yml │ └── java │ │ └── me │ │ └── chrisumb │ │ └── customentitytest │ │ ├── FlyingFishEntityType.java │ │ ├── ShopkeeperEntityType.java │ │ └── CustomEntityTestPlugin.java └── main │ └── java │ └── me │ └── chrisumb │ └── customentity │ ├── listeners │ ├── PlayerPacketListener.java │ └── CustomEntityListener.java │ ├── Skin.java │ ├── packets │ └── PacketInterceptor.java │ └── CustomEntityType.java ├── gradlew.bat ├── spigot.gradle ├── README.md └── gradlew /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'custom-entity' 2 | 3 | -------------------------------------------------------------------------------- /img/zombie-player-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisUMB/custom-entity/HEAD/img/zombie-player-example.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /.gradle/ 3 | /build/ 4 | /build/classes/java/main/ 5 | /build/classes/java/test/ -------------------------------------------------------------------------------- /src/test/resources/plugin.yml: -------------------------------------------------------------------------------- 1 | name: "CustomEntityTest" 2 | version: 1.0 3 | main: me.chrisumb.customentitytest.CustomEntityTestPlugin 4 | commands: 5 | customentity: 6 | description: "Spawn a test entity." 7 | usage: "/customentity" -------------------------------------------------------------------------------- /src/test/java/me/chrisumb/customentitytest/FlyingFishEntityType.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentitytest; 2 | 3 | import me.chrisumb.customentity.CustomEntityType; 4 | import org.bukkit.entity.*; 5 | import org.jetbrains.annotations.NotNull; 6 | 7 | public class FlyingFishEntityType extends CustomEntityType { 8 | 9 | public FlyingFishEntityType(@NotNull String id) { 10 | super(id, Bat.class, Salmon.class); 11 | } 12 | 13 | @Override 14 | public void onSpawn(@NotNull Bat entity) { 15 | entity.setSilent(true); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/me/chrisumb/customentity/listeners/PlayerPacketListener.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentity.listeners; 2 | 3 | import io.netty.channel.Channel; 4 | import io.netty.channel.ChannelPipeline; 5 | import me.chrisumb.customentity.packets.PacketInterceptor; 6 | import org.bukkit.craftbukkit.v1_17_R1.entity.CraftPlayer; 7 | import org.bukkit.entity.Player; 8 | import org.bukkit.event.EventHandler; 9 | import org.bukkit.event.Listener; 10 | import org.bukkit.event.player.PlayerJoinEvent; 11 | import org.bukkit.plugin.java.JavaPlugin; 12 | 13 | /** 14 | * This listener is responsible for injecting our {@link PacketInterceptor} into the 15 | * player's packet handling {@link ChannelPipeline}. 16 | */ 17 | public final class PlayerPacketListener implements Listener { 18 | 19 | private final JavaPlugin plugin; 20 | 21 | public PlayerPacketListener(JavaPlugin plugin) { 22 | this.plugin = plugin; 23 | } 24 | 25 | @EventHandler 26 | public void onPlayerJoin(PlayerJoinEvent event) { 27 | Player player = event.getPlayer(); 28 | Channel channel = ((CraftPlayer) player).getHandle().b.a.k; 29 | ChannelPipeline pipeline = channel.pipeline(); 30 | 31 | //Does the player's packet pipeline already have custom_entity? If so, return. 32 | if (pipeline.get("custom_entity") != null) { 33 | return; 34 | } 35 | 36 | //Does the packet handler context exist? If not, return. 37 | if (pipeline.context("packet_handler") == null) { 38 | return; 39 | } 40 | 41 | //All clear, add the interceptor before the packet_handler context. 42 | pipeline.addBefore("packet_handler", "custom_entity", new PacketInterceptor(plugin, player)); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/me/chrisumb/customentitytest/ShopkeeperEntityType.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentitytest; 2 | 3 | import me.chrisumb.customentity.CustomEntityType; 4 | import me.chrisumb.customentity.Skin; 5 | import org.bukkit.ChatColor; 6 | import org.bukkit.Material; 7 | import org.bukkit.entity.Player; 8 | import org.bukkit.entity.Zombie; 9 | import org.bukkit.event.player.PlayerInteractAtEntityEvent; 10 | import org.bukkit.inventory.EntityEquipment; 11 | import org.bukkit.inventory.EquipmentSlot; 12 | import org.bukkit.inventory.ItemStack; 13 | import org.bukkit.plugin.java.JavaPlugin; 14 | import org.jetbrains.annotations.NotNull; 15 | 16 | import java.io.File; 17 | 18 | public final class ShopkeeperEntityType extends CustomEntityType { 19 | 20 | private final String phrase; 21 | 22 | public ShopkeeperEntityType(JavaPlugin plugin, String id, String phrase) { 23 | super(id + "-shopkeeper", Skin.load(new File(plugin.getDataFolder(), "tom1024.skin")), Zombie.class, Player.class); 24 | this.phrase = phrase; 25 | } 26 | 27 | @Override 28 | public void onRightClick(@NotNull Zombie entity, @NotNull Player player, @NotNull PlayerInteractAtEntityEvent event) { 29 | if (event.getHand() != EquipmentSlot.HAND) { 30 | return; 31 | } 32 | 33 | player.sendMessage(phrase); 34 | } 35 | 36 | @Override 37 | public void onSpawn(@NotNull Zombie entity) { 38 | entity.setSilent(true); 39 | entity.setShouldBurnInDay(false); 40 | 41 | entity.setCustomNameVisible(true); 42 | entity.setCustomName(ChatColor.translateAlternateColorCodes('&', "&c&lFood Shopkeeper")); 43 | 44 | EntityEquipment equipment = entity.getEquipment(); 45 | equipment.setItem(EquipmentSlot.HAND, new ItemStack(Material.DIAMOND_SWORD)); 46 | } 47 | 48 | public String getPhrase() { 49 | return phrase; 50 | } 51 | } -------------------------------------------------------------------------------- /src/test/java/me/chrisumb/customentitytest/CustomEntityTestPlugin.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentitytest; 2 | 3 | import me.chrisumb.customentity.CustomEntityType; 4 | import org.bukkit.command.Command; 5 | import org.bukkit.command.CommandSender; 6 | import org.bukkit.entity.Player; 7 | import org.bukkit.plugin.java.JavaPlugin; 8 | import org.jetbrains.annotations.NotNull; 9 | 10 | import java.io.File; 11 | 12 | public final class CustomEntityTestPlugin extends JavaPlugin { 13 | 14 | public static ShopkeeperEntityType foodShopkeeper; 15 | public static ShopkeeperEntityType redstoneShopkeeper; 16 | public static final FlyingFishEntityType FLYING_FISH = new FlyingFishEntityType("flying-fish"); 17 | 18 | @Override 19 | public void onEnable() { 20 | if (!new File(getDataFolder(), "tom1024.skin").exists()) { 21 | getSLF4JLogger().error("Trying to load example, but tom1024.skin doesn't exist! Replace it with something else."); 22 | } 23 | 24 | // These have to be instantiated like this because they need the plugin instance. 25 | foodShopkeeper = new ShopkeeperEntityType(this, "food", "Buy my food!"); 26 | redstoneShopkeeper = new ShopkeeperEntityType(this, "redstone", "I sell redstone."); 27 | 28 | getLogger().info("Registering our test custom entities..."); 29 | CustomEntityType.register(foodShopkeeper); 30 | CustomEntityType.register(redstoneShopkeeper); 31 | CustomEntityType.register(FLYING_FISH); 32 | } 33 | 34 | @Override 35 | public boolean onCommand( 36 | @NotNull CommandSender sender, 37 | @NotNull Command command, 38 | @NotNull String label, 39 | String[] args 40 | ) { 41 | 42 | if (!(sender instanceof Player player)) { 43 | return true; 44 | } 45 | 46 | foodShopkeeper.spawn(player.getLocation()); 47 | return true; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /spigot.gradle: -------------------------------------------------------------------------------- 1 | import javax.net.ssl.HttpsURLConnection 2 | import java.nio.file.Files 3 | import java.nio.file.Paths 4 | import java.nio.file.StandardCopyOption 5 | 6 | final String SERVER_MEMORY = "4G" 7 | final String VERSION = "1.17.1" 8 | final String SERVER_DOWNLOAD = "https://papermc.io/api/v2/projects/paper/versions/1.17.1/builds/325/downloads/paper-1.17.1-325.jar" 9 | final String GROUP_NAME = "server [${VERSION}]" 10 | final File RUN_FOLDER = new File(project.rootDir, "run-${VERSION}") 11 | 12 | task copyToPlugins(type: Copy) { 13 | group GROUP_NAME 14 | from testShadowJar 15 | into new File(RUN_FOLDER, "plugins") 16 | } 17 | 18 | task setupTestServer(type: Exec) { 19 | group GROUP_NAME 20 | 21 | doFirst { 22 | def serverDir = RUN_FOLDER 23 | serverDir.mkdir() 24 | 25 | def settingsDir = new File(serverDir, "settings") 26 | settingsDir.mkdir() 27 | 28 | def eula = new File(settingsDir, "eula.txt") 29 | try(def writer = new FileWriter(eula)) { 30 | writer.write("eula=true") 31 | } 32 | 33 | new File(serverDir, "plugins").mkdir() 34 | new File(serverDir, "worlds").mkdir() 35 | createServerJar(SERVER_DOWNLOAD, serverDir) 36 | } 37 | 38 | workingDir new File(RUN_FOLDER, "settings") 39 | standardOutput = new ByteArrayOutputStream() 40 | commandLine 'java', '-jar', '../server.jar', '--help' 41 | } 42 | 43 | 44 | task runTestServer(type: Exec, dependsOn: [copyToPlugins]) { 45 | group GROUP_NAME 46 | workingDir new File(RUN_FOLDER, "settings") 47 | commandLine 'java', 48 | '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005', 49 | "-Xmx${SERVER_MEMORY}", 50 | "-Xms${SERVER_MEMORY}", 51 | '-XX:+UseG1GC', 52 | '-XX:+ParallelRefProcEnabled', 53 | '-XX:MaxGCPauseMillis=200', 54 | '-XX:+UnlockExperimentalVMOptions', 55 | '-XX:+DisableExplicitGC', 56 | '-XX:+AlwaysPreTouch', 57 | '-XX:G1NewSizePercent=30', 58 | '-XX:G1MaxNewSizePercent=40', 59 | '-XX:G1HeapRegionSize=8M', 60 | '-XX:G1ReservePercent=20', 61 | '-XX:G1HeapWastePercent=5', 62 | '-XX:G1MixedGCCountTarget=4', 63 | '-XX:InitiatingHeapOccupancyPercent=15', 64 | '-XX:G1MixedGCLiveThresholdPercent=90', 65 | '-XX:G1RSetUpdatingPauseTimePercent=5', 66 | '-XX:SurvivorRatio=32', 67 | '-XX:+UseCompressedOops', 68 | '-XX:+PerfDisableSharedMem', 69 | '-XX:MaxTenuringThreshold=1', 70 | '-Dusing.aikars.flags=https://mcflags.emc.gs', 71 | '-Daikars.new.flags=true', 72 | '-jar', '../server.jar', 73 | '-P', '../plugins', 74 | '-W', '../worlds', 75 | '-nogui' 76 | standardInput = System.in 77 | } 78 | 79 | 80 | static void createServerJar(String serverDownloadURL, File serverDir) { 81 | def url = new URL(serverDownloadURL) 82 | def connection = url.openConnection() as HttpsURLConnection 83 | connection.requestMethod = "GET" 84 | connection.doInput = true 85 | connection.setRequestProperty("User-Agent", "SpigotGradle/1.0") 86 | 87 | def serverFile = new File(serverDir, 'server.jar') 88 | Files.copy(connection.inputStream, 89 | Paths.get(serverFile.absolutePath), 90 | StandardCopyOption.REPLACE_EXISTING, 91 | ) 92 | 93 | connection.disconnect() 94 | } 95 | 96 | dependencies { 97 | implementation fileTree("run-${VERSION}/settings/cache/patched_${VERSION}.jar") 98 | } -------------------------------------------------------------------------------- /src/main/java/me/chrisumb/customentity/listeners/CustomEntityListener.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentity.listeners; 2 | 3 | import me.chrisumb.customentity.CustomEntityType; 4 | import org.bukkit.entity.Entity; 5 | import org.bukkit.entity.LivingEntity; 6 | import org.bukkit.entity.Player; 7 | import org.bukkit.event.EventHandler; 8 | import org.bukkit.event.Listener; 9 | import org.bukkit.event.entity.EntityDamageByBlockEvent; 10 | import org.bukkit.event.entity.EntityDamageByEntityEvent; 11 | import org.bukkit.event.entity.EntityDamageEvent; 12 | import org.bukkit.event.entity.EntityDeathEvent; 13 | import org.bukkit.event.player.PlayerInteractAtEntityEvent; 14 | import org.jetbrains.annotations.Nullable; 15 | 16 | @SuppressWarnings("unchecked") 17 | public class CustomEntityListener implements Listener { 18 | 19 | @EventHandler 20 | public void onPlayerInteract(PlayerInteractAtEntityEvent event) { 21 | Player player = event.getPlayer(); 22 | Entity entity = event.getRightClicked(); 23 | 24 | CustomEntityType customEntityType = getCustomEntityType(entity); 25 | 26 | if (customEntityType == null) { 27 | return; 28 | } 29 | 30 | customEntityType.onRightClick(entity, player, event); 31 | } 32 | 33 | @EventHandler 34 | public void onEntityDamage(EntityDamageEvent event) { 35 | 36 | Entity entity = event.getEntity(); 37 | 38 | CustomEntityType customEntityType = getCustomEntityType(entity); 39 | 40 | if (customEntityType == null) { 41 | return; 42 | } 43 | 44 | customEntityType.onDamage(entity, event); 45 | 46 | } 47 | 48 | @EventHandler 49 | public void onEntityDamageByEntity(EntityDamageByEntityEvent event) { 50 | Entity entity = event.getEntity(); 51 | Entity damager = event.getDamager(); 52 | 53 | CustomEntityType damagedCustomEntityType = getCustomEntityType(entity); 54 | 55 | if (damagedCustomEntityType != null) { 56 | damagedCustomEntityType.onDamageByEntity(entity, damager, event); 57 | } 58 | 59 | CustomEntityType damagerCustomEntityType = getCustomEntityType(damager); 60 | 61 | if (damagerCustomEntityType != null) { 62 | damagerCustomEntityType.onDamageToEntity(damager, entity, event); 63 | } 64 | } 65 | 66 | @EventHandler 67 | public void onEntityDamageByBlock(EntityDamageByBlockEvent event) { 68 | Entity entity = event.getEntity(); 69 | 70 | CustomEntityType customEntityType = getCustomEntityType(entity); 71 | 72 | if (customEntityType == null) { 73 | return; 74 | } 75 | 76 | customEntityType.onDamageByBlock(entity, event.getDamager(), event); 77 | } 78 | 79 | @EventHandler 80 | public void onEntityDeath(EntityDeathEvent event) { 81 | LivingEntity entity = event.getEntity(); 82 | 83 | CustomEntityType customEntityType = getCustomEntityType(entity); 84 | 85 | if (customEntityType == null) { 86 | return; 87 | } 88 | 89 | customEntityType.onDeath(entity, event); 90 | } 91 | 92 | @Nullable 93 | private CustomEntityType getCustomEntityType(Entity entity) { 94 | CustomEntityType customEntityType = (CustomEntityType) CustomEntityType.get(entity); 95 | 96 | if (customEntityType == null) { 97 | return null; 98 | } 99 | 100 | Class internalType = customEntityType.getInternalEntityClass(); 101 | 102 | if (!internalType.isInstance(entity)) { 103 | return null; 104 | } 105 | 106 | return customEntityType; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Entity 2 | 3 | ### This project was made as a simple utility for spawning entities that look one way to the client, but are internally something else on the server. 4 | 5 | #### Example: This is actually a zombie, with zombie AI, but looks like a player. Neat! 6 | ![Zombie -> Player Example](img/zombie-player-example.gif) 7 | 8 | ## Dependency 9 | ##### Note: You need to also have the server as a dependency for NMS access. 10 | ```groovy 11 | repository { 12 | maven 'https://sparse.blue/maven' 13 | } 14 | 15 | dependency { 16 | implementation 'me.chrisumb:custom-entity:1.0' 17 | } 18 | ``` 19 | 20 | ## Usage 21 | 22 | ### This is an example of a "shopkeeper", it's a zombie that looks like a player, with a right click functionality to say a phrase. He will also follow players around and try to attack them like a zombie would. 23 | 24 | ```java 25 | public final class ShopkeeperEntityType extends CustomEntityType { 26 | 27 | private final String phrase; 28 | /* 29 | This will be a zombie that looks like a player, with the skin of player "Tom1024". 30 | 31 | You can do any combination of spawnable entities, not just players, and there is an alternative constructor that doesn't take a skin in case you don't want to spawn a player on the client end. 32 | */ 33 | public ShopkeeperEntityType(JavaPlugin plugin, String id, String phrase) { 34 | super(id + "-shopkeeper", Skin.load(new File(plugin.getDataFolder(), "tom1024.skin")), Zombie.class, Player.class); 35 | this.phrase = phrase; 36 | } 37 | 38 | @Override 39 | public void onRightClick(@NotNull Zombie entity, @NotNull Player player, @NotNull PlayerInteractAtEntityEvent event) { 40 | if (event.getHand() != EquipmentSlot.HAND) { 41 | return; 42 | } 43 | 44 | player.sendMessage(phrase); 45 | } 46 | 47 | @Override 48 | public void onSpawn(@NotNull Zombie entity) { 49 | entity.setSilent(true); 50 | entity.setShouldBurnInDay(false); 51 | 52 | entity.setCustomNameVisible(true); 53 | entity.setCustomName("Food Shopkeeper"); 54 | 55 | EntityEquipment equipment = entity.getEquipment(); 56 | equipment.setItem(EquipmentSlot.HAND, new ItemStack(Material.DIAMOND_SWORD)); 57 | } 58 | 59 | public String getPhrase() { 60 | return phrase; 61 | } 62 | } 63 | ``` 64 | ## Registering a custom entity 65 | ```java 66 | private static ShopkeeperEntityType foodShopkeeper; 67 | 68 | @Override 69 | public void onEnable() { 70 | // It's instantiated here in the onEnable() because it needs access to the plugin. 71 | foodShopkeeper = new ShopkeeperEntityType(this, "food", "Buy my food!"); 72 | CustomEntityType.register(foodShopkeeper); 73 | } 74 | ``` 75 | 76 | ## Spawning a custom entity 77 | ```java 78 | foodShopkeeper.spawn(player.getLocation()); 79 | ``` 80 | 81 | 82 | 83 | # Notes 84 | 85 | I don't plan on providing continued support on this project, I just made it for 1.17.1 because that's what I use currently. 86 | 87 | The entities created are persistent across server restarts. Some use cases I think are good are dynamic shop NPC's that have some pathfinding on their base entity, but it's also fun to just make random mobs behave like others. Try a bat that looks like a horse, it's just funny. 88 | 89 | This heavily relies on packet interception, so it's pretty prone to cause issues. There very well may be some client side exceptions that don't kick the client, but it complains about, which I tried my best to remedy the ones I found. Furthermore, there is some asyncronous entity accessing which probably shouldn't happen, but I have no idea what else I would do. 90 | 91 | The skin format is just `value` `\n` `signature`. You can make a `.skin` file easily enough manually by going to https://sessionserver.mojang.com/session/minecraft/profile/%s?unsigned=false and replacing `%s` with the UUID you get from https://api.mojang.com/users/profiles/minecraft/%s where you replace `%s` with a username. 92 | 93 | If you want to fork this, you need to run the `setupTestServer` task, then refresh gradle so that you have the test server and the `.jar` files necessary. -------------------------------------------------------------------------------- /src/main/java/me/chrisumb/customentity/Skin.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentity; 2 | 3 | import org.json.simple.JSONArray; 4 | import org.json.simple.JSONObject; 5 | import org.json.simple.parser.JSONParser; 6 | import org.json.simple.parser.ParseException; 7 | 8 | import java.io.*; 9 | import java.net.URL; 10 | import java.net.URLConnection; 11 | import java.nio.file.Files; 12 | import java.nio.file.StandardOpenOption; 13 | import java.util.UUID; 14 | 15 | /** 16 | * A Minecraft skin, represented as it's value and signature. 17 | */ 18 | public final class Skin { 19 | 20 | /** 21 | * The data of the skin. 22 | */ 23 | private final String value; 24 | /** 25 | * The Mojang required signature. 26 | */ 27 | private final String signature; 28 | 29 | private Skin(String value, String signature) { 30 | this.value = value; 31 | this.signature = signature; 32 | } 33 | 34 | public String getValue() { 35 | return value; 36 | } 37 | 38 | public String getSignature() { 39 | return signature; 40 | } 41 | 42 | /** 43 | * Saves this skin to a file. 44 | * 45 | * @param file The {@link File} to write this to. 46 | */ 47 | public void save(File file) { 48 | try { 49 | Files.writeString(file.toPath(), value + "\n" + signature, StandardOpenOption.CREATE); 50 | } catch (IOException e) { 51 | e.printStackTrace(); 52 | } 53 | } 54 | 55 | private static final String SKIN_DATA_UUID_DOWNLOAD_URL = 56 | "https://sessionserver.mojang.com/session/minecraft/profile/%s?unsigned=false"; 57 | 58 | private static final String PLAYER_UUID_FROM_USERNAME_URL = "https://api.mojang.com/users/profiles/minecraft/%s"; 59 | 60 | private static UUID toRealUUID(String mojangUUID) { 61 | String least = mojangUUID.substring(0, 16); 62 | String most = mojangUUID.substring(16); 63 | return new UUID(Long.parseUnsignedLong(least, 16), Long.parseUnsignedLong(most, 16)); 64 | } 65 | 66 | public static Skin load(File file) { 67 | try { 68 | String s = Files.readString(file.toPath()); 69 | 70 | String[] split = s.split("\r?\n"); 71 | 72 | if (split.length <= 0) { 73 | return null; 74 | } 75 | 76 | return new Skin(split[0], split[1]); 77 | } catch (IOException e) { 78 | e.printStackTrace(); 79 | } 80 | 81 | return null; 82 | } 83 | 84 | /** 85 | * It's worth noting that downloading using a username is less efficient. 86 | * 87 | * @param username The username of the player to download the skin of. 88 | * @return The {@link Skin} of the player, or null if something went wrong. 89 | */ 90 | public static Skin download(String username) { 91 | String url = String.format(PLAYER_UUID_FROM_USERNAME_URL, username); 92 | try { 93 | URLConnection connection = new URL(url).openConnection(); 94 | InputStreamReader inputStream = new InputStreamReader(connection.getInputStream()); 95 | JSONObject jsonObject = (JSONObject) new JSONParser().parse(inputStream); 96 | 97 | String id = (String) jsonObject.get("id"); 98 | return download(toRealUUID(id)); 99 | 100 | } catch (IOException | ParseException e) { 101 | e.printStackTrace(); 102 | } 103 | 104 | return null; 105 | } 106 | 107 | /** 108 | * @param id The UUID of the player to download the skin of. 109 | * @return The {@link Skin} of the player, or null if something went wrong. 110 | */ 111 | public static Skin download(UUID id) { 112 | String url = String.format(SKIN_DATA_UUID_DOWNLOAD_URL, id.toString().replace("-", "")); 113 | 114 | try { 115 | URLConnection connection = new URL(url).openConnection(); 116 | InputStreamReader inputStream = new InputStreamReader(connection.getInputStream()); 117 | 118 | JSONObject jsonObject = (JSONObject) new JSONParser().parse(inputStream); 119 | 120 | JSONArray properties = (JSONArray) jsonObject.get("properties"); 121 | JSONObject property = (JSONObject) properties.get(0); 122 | 123 | return new Skin((String) property.get("value"), (String) property.get("signature")); 124 | 125 | } catch (IOException | ParseException e) { 126 | e.printStackTrace(); 127 | } 128 | 129 | return null; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /src/main/java/me/chrisumb/customentity/packets/PacketInterceptor.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentity.packets; 2 | 3 | import com.mojang.authlib.GameProfile; 4 | import com.mojang.authlib.properties.Property; 5 | import io.netty.buffer.Unpooled; 6 | import io.netty.channel.ChannelDuplexHandler; 7 | import io.netty.channel.ChannelHandlerContext; 8 | import io.netty.channel.ChannelPromise; 9 | import io.netty.channel.DefaultChannelPromise; 10 | import it.unimi.dsi.fastutil.ints.IntArrayList; 11 | import it.unimi.dsi.fastutil.ints.IntList; 12 | import me.chrisumb.customentity.CustomEntityType; 13 | import me.chrisumb.customentity.Skin; 14 | import net.minecraft.network.PacketDataSerializer; 15 | import net.minecraft.network.chat.IChatBaseComponent; 16 | import net.minecraft.network.protocol.Packet; 17 | import net.minecraft.network.protocol.game.*; 18 | import net.minecraft.network.syncher.DataWatcher; 19 | import net.minecraft.server.MinecraftServer; 20 | import net.minecraft.server.level.EntityPlayer; 21 | import net.minecraft.server.level.WorldServer; 22 | import net.minecraft.world.entity.Entity; 23 | import net.minecraft.world.level.entity.LevelEntityGetter; 24 | import net.minecraft.world.scores.Scoreboard; 25 | import net.minecraft.world.scores.ScoreboardTeam; 26 | import net.minecraft.world.scores.ScoreboardTeamBase; 27 | import org.bukkit.Bukkit; 28 | import org.bukkit.Location; 29 | import org.bukkit.craftbukkit.v1_17_R1.CraftWorld; 30 | import org.bukkit.craftbukkit.v1_17_R1.entity.CraftPlayer; 31 | import org.bukkit.entity.EntityType; 32 | import org.bukkit.entity.Player; 33 | import org.bukkit.plugin.java.JavaPlugin; 34 | import org.jetbrains.annotations.Nullable; 35 | 36 | import java.lang.reflect.Field; 37 | import java.util.ArrayList; 38 | import java.util.List; 39 | import java.util.Optional; 40 | import java.util.UUID; 41 | 42 | /** 43 | * This is responsible for masking the custom entities as {@link EntityPlayer} for clients. 44 | */ 45 | public final class PacketInterceptor extends ChannelDuplexHandler { 46 | 47 | private final JavaPlugin plugin; 48 | 49 | private final Player player; 50 | private final CraftPlayer craftPlayer; 51 | 52 | private final IntList entityIDs = new IntArrayList(); 53 | 54 | /** 55 | * @param player The player to handle the packet interception for. 56 | */ 57 | public PacketInterceptor(JavaPlugin plugin, Player player) { 58 | this.plugin = plugin; 59 | this.player = player; 60 | this.craftPlayer = (CraftPlayer) player; 61 | } 62 | 63 | @Override 64 | public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { 65 | if (!(msg instanceof Packet packet)) { 66 | return; 67 | } 68 | 69 | if (packet instanceof PacketPlayOutSpawnEntityExperienceOrb xpOrbPacket) { 70 | int entityID = xpOrbPacket.b(); 71 | handleEntitySpawn(ctx, msg, promise, entityID); 72 | return; 73 | } 74 | 75 | if (packet instanceof PacketPlayOutSpawnEntity spawnEntityPacket) { 76 | int entityID = spawnEntityPacket.b(); 77 | handleEntitySpawn(ctx, msg, promise, entityID); 78 | return; 79 | } 80 | 81 | if (packet instanceof PacketPlayOutSpawnEntityLiving spawnEntityLivingPacket) { 82 | int entityID = spawnEntityLivingPacket.b(); 83 | handleEntitySpawn(ctx, msg, promise, entityID); 84 | return; 85 | } 86 | 87 | if (packet instanceof PacketPlayOutEntityHeadRotation entityHeadRotationPacket) { 88 | Entity entity = getEntity(entityHeadRotationPacket); 89 | 90 | if (entity == null) { 91 | super.write(ctx, msg, promise); 92 | return; 93 | } 94 | 95 | CustomEntityType customEntityType = CustomEntityType.get(entity.getBukkitEntity()); 96 | 97 | //If the custom entity was null, we don't want this packet either. 98 | if (customEntityType == null) { 99 | super.write(ctx, msg, promise); 100 | return; 101 | } 102 | 103 | byte yaw = entityHeadRotationPacket.b(); 104 | 105 | entity.setLocation( 106 | entity.locX(), 107 | entity.locY(), 108 | entity.locZ(), 109 | (yaw / 256f) * 360f, 110 | entity.getXRot() 111 | ); 112 | 113 | PacketPlayOutEntityTeleport entityTeleport = new PacketPlayOutEntityTeleport(entity); 114 | 115 | super.write(ctx, entityTeleport, promise); 116 | super.write(ctx, packet, new DefaultChannelPromise(ctx.channel())); 117 | return; 118 | } 119 | 120 | if (packet instanceof PacketPlayOutEntity.PacketPlayOutEntityLook lookPacket) { 121 | Entity entity = getEntity(lookPacket); 122 | 123 | if (entity == null) { 124 | super.write(ctx, msg, promise); 125 | return; 126 | } 127 | 128 | CustomEntityType customEntityType = CustomEntityType.get(entity.getBukkitEntity()); 129 | 130 | //If the custom entity was null, we don't want this packet either. 131 | if (customEntityType == null) { 132 | super.write(ctx, msg, promise); 133 | } 134 | 135 | //We want to ignore these packets as we manage them ourselves with Teleport packets in the HeadRotation listener. 136 | return; 137 | } 138 | 139 | if (packet instanceof PacketPlayOutEntityMetadata metadataPacket) { 140 | int entityID = metadataPacket.c(); 141 | Entity entity = getEntity(entityID); 142 | 143 | if (entity == null) { 144 | super.write(ctx, msg, promise); 145 | return; 146 | } 147 | 148 | List> watchers = metadataPacket.b(); 149 | List> remaining = new ArrayList<>(); 150 | if (watchers != null) { 151 | 152 | DataWatcher.Item customName = null; 153 | 154 | for (DataWatcher.Item watcher : watchers) { 155 | int id = watcher.a().a(); 156 | if (id < 15 || id > 20) { 157 | remaining.add(watcher); 158 | } 159 | 160 | if (id == 2) { 161 | customName = watcher; 162 | } 163 | } 164 | 165 | // watchers.removeAll(remaining); 166 | 167 | if (customName != null) { 168 | Optional name = (Optional) customName.b(); 169 | 170 | if (entity.getCustomNameVisible()) { 171 | Scoreboard scoreboard = new Scoreboard(); 172 | ScoreboardTeam team = new ScoreboardTeam(scoreboard, getInvisibleName(entityID)); 173 | if (name.isPresent()) { 174 | team.setNameTagVisibility(ScoreboardTeamBase.EnumNameTagVisibility.a); 175 | team.setPrefix(name.get()); 176 | } else { 177 | team.setNameTagVisibility(ScoreboardTeamBase.EnumNameTagVisibility.b); 178 | } 179 | 180 | team.getPlayerNameSet().add(getInvisibleName(entityID)); 181 | 182 | PacketPlayOutScoreboardTeam teamPacket = PacketPlayOutScoreboardTeam.a(team, false); 183 | super.write(ctx, teamPacket, new DefaultChannelPromise(ctx.channel())); 184 | } 185 | } 186 | 187 | if (remaining.isEmpty()) { 188 | return; 189 | } 190 | 191 | // if (!remaining.isEmpty()) { 192 | PacketDataSerializer serializer = new PacketDataSerializer(Unpooled.buffer()); 193 | serializer.d(entityID); 194 | DataWatcher.a(remaining, serializer); 195 | PacketPlayOutEntityMetadata newPacket = new PacketPlayOutEntityMetadata(serializer); 196 | super.write(ctx, newPacket, promise); 197 | return; 198 | // } 199 | } 200 | } 201 | 202 | if (packet instanceof PacketPlayOutEntityDestroy destroyPacket) { 203 | IntList destroyedEntityIDs = destroyPacket.b(); 204 | 205 | for (int entityID : destroyedEntityIDs) { 206 | if (!entityIDs.contains(entityID)) { 207 | continue; 208 | } 209 | 210 | Scoreboard scoreboard = new Scoreboard(); 211 | ScoreboardTeam team = new ScoreboardTeam(scoreboard, getInvisibleName(entityID)); 212 | PacketPlayOutScoreboardTeam removeTeamPacket = PacketPlayOutScoreboardTeam.a(team); 213 | super.write(ctx, removeTeamPacket, new DefaultChannelPromise(ctx.channel())); 214 | } 215 | 216 | entityIDs.removeAll(destroyedEntityIDs); 217 | } 218 | 219 | //If it reaches this point, we didn't want to intercept or modify this packet, so we call super.write 220 | //to make sure it still gets to the client. 221 | super.write(ctx, msg, promise); 222 | } 223 | 224 | private void handleEntitySpawn(ChannelHandlerContext ctx, Object msg, ChannelPromise promise, int entityID) throws Exception { 225 | Entity entity = getEntity(entityID); 226 | 227 | //If the entity was null, we don't want anything to do with this packet. 228 | if (entity == null) { 229 | super.write(ctx, msg, promise); 230 | return; 231 | } 232 | 233 | MinecraftServer minecraftServer = entity.getMinecraftServer(); 234 | 235 | if (minecraftServer == null) { 236 | super.write(ctx, msg, promise); 237 | return; 238 | } 239 | 240 | CustomEntityType customEntityType = CustomEntityType.get(entity.getBukkitEntity()); 241 | 242 | //If the custom entity was null, we don't want this packet either. 243 | if (customEntityType == null) { 244 | super.write(ctx, msg, promise); 245 | return; 246 | } 247 | 248 | entityIDs.add(entityID); 249 | EntityType displayType = customEntityType.getDisplayType(); 250 | 251 | if (displayType == EntityType.PLAYER) { 252 | 253 | GameProfile profile = new GameProfile(UUID.randomUUID(), getInvisibleName(entityID)); 254 | 255 | Skin skin = customEntityType.getSkin(); 256 | if (skin != null) { 257 | profile.getProperties().put("textures", new Property("textures", skin.getValue(), skin.getSignature())); 258 | } 259 | 260 | EntityPlayer fakePlayer = new EntityPlayer( 261 | minecraftServer, 262 | entity.getWorld().getMinecraftWorld(), 263 | profile 264 | ); 265 | 266 | fakePlayer.setLocation( 267 | entity.locX(), entity.locY(), entity.locZ(), 268 | entity.getYRot(), entity.getXRot() 269 | ); 270 | 271 | //This is the money, make the client associate all incoming packets from 272 | //the real, server managed entity as a fake player instead. 273 | fakePlayer.e(entityID); 274 | 275 | PacketPlayOutPlayerInfo infoPacket 276 | = new PacketPlayOutPlayerInfo(PacketPlayOutPlayerInfo.EnumPlayerInfoAction.a, fakePlayer); 277 | 278 | PacketPlayOutNamedEntitySpawn spawnPacket = new PacketPlayOutNamedEntitySpawn(fakePlayer); 279 | 280 | Scoreboard scoreboard = new Scoreboard(); 281 | ScoreboardTeam team = new ScoreboardTeam(scoreboard, getInvisibleName(entityID)); 282 | team.setNameTagVisibility(ScoreboardTeamBase.EnumNameTagVisibility.b); 283 | team.getPlayerNameSet().add(getInvisibleName(entityID)); 284 | 285 | PacketPlayOutScoreboardTeam teamPacket = PacketPlayOutScoreboardTeam.a(team, true); 286 | 287 | super.write(ctx, infoPacket, promise); 288 | super.write(ctx, spawnPacket, new DefaultChannelPromise(ctx.channel())); 289 | super.write(ctx, teamPacket, new DefaultChannelPromise(ctx.channel())); 290 | 291 | //This is done to get the fake player name out of tablist. If it's not delayed, the skin won't download. 292 | int delay = skin == null ? 0 : 40; 293 | 294 | Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> { 295 | PacketPlayOutPlayerInfo removeTablistPacket 296 | = new PacketPlayOutPlayerInfo(PacketPlayOutPlayerInfo.EnumPlayerInfoAction.e, fakePlayer); 297 | 298 | sendPacket(player, removeTablistPacket); 299 | }, delay); 300 | 301 | } else { 302 | 303 | CraftWorld world = (CraftWorld) player.getWorld(); 304 | Location location = new Location(world, entity.locX(), entity.locY(), entity.locZ(), entity.getYRot(), entity.getXRot()); 305 | Entity fakeEntity = world.createEntity(location, customEntityType.getDisplayEntityClass(), false); 306 | fakeEntity.e(entityID); 307 | 308 | PacketPlayOutSpawnEntity spawnPacket = new PacketPlayOutSpawnEntity(fakeEntity); 309 | 310 | super.write(ctx, spawnPacket, promise); 311 | } 312 | } 313 | 314 | private static Field entityPacketEntityIDField; 315 | private static Field headRotationPacketEntityIDField; 316 | 317 | static { 318 | try { 319 | entityPacketEntityIDField = PacketPlayOutEntity.class.getDeclaredField("a"); 320 | entityPacketEntityIDField.setAccessible(true); 321 | 322 | headRotationPacketEntityIDField = PacketPlayOutEntityHeadRotation.class.getDeclaredField("a"); 323 | headRotationPacketEntityIDField.setAccessible(true); 324 | } catch (NoSuchFieldException e) { 325 | e.printStackTrace(); 326 | } 327 | } 328 | 329 | private void sendPacket(Player player, Packet packet) { 330 | ((CraftPlayer) player).getHandle().b.sendPacket(packet); 331 | } 332 | 333 | /** 334 | * A convenience method for getting the {@link Entity} by ID from the internal {@link LevelEntityGetter}, 335 | * as this *should* be a thread safe approach to getting the entity instance. 336 | * 337 | * @param entityID The ID of the {@link Entity} to find. 338 | * @return The {@link Entity} if found, otherwise, null. 339 | */ 340 | @Nullable 341 | private Entity getEntity(int entityID) { 342 | EntityPlayer entityPlayer = craftPlayer.getHandle(); 343 | return ((WorldServer) entityPlayer.t).G.d().a(entityID); 344 | } 345 | 346 | @Nullable 347 | private Entity getEntity(PacketPlayOutEntity packet) { 348 | try { 349 | int entityID = entityPacketEntityIDField.getInt(packet); 350 | return getEntity(entityID); 351 | } catch (IllegalAccessException e) { 352 | e.printStackTrace(); 353 | } 354 | 355 | return null; 356 | } 357 | 358 | @Nullable 359 | private Entity getEntity(PacketPlayOutEntityHeadRotation packet) { 360 | try { 361 | int entityID = headRotationPacketEntityIDField.getInt(packet); 362 | return getEntity(entityID); 363 | } catch (IllegalAccessException e) { 364 | e.printStackTrace(); 365 | } 366 | 367 | return null; 368 | } 369 | 370 | private String getInvisibleName(int id) { 371 | return Integer.toHexString(id).replaceAll("(.)", "\u00a7$1"); 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/main/java/me/chrisumb/customentity/CustomEntityType.java: -------------------------------------------------------------------------------- 1 | package me.chrisumb.customentity; 2 | 3 | import io.papermc.paper.event.entity.EntityMoveEvent; 4 | import me.chrisumb.customentity.listeners.CustomEntityListener; 5 | import me.chrisumb.customentity.listeners.PlayerPacketListener; 6 | import org.bukkit.Bukkit; 7 | import org.bukkit.Location; 8 | import org.bukkit.NamespacedKey; 9 | import org.bukkit.World; 10 | import org.bukkit.block.Block; 11 | import org.bukkit.entity.Entity; 12 | import org.bukkit.entity.EntityType; 13 | import org.bukkit.entity.Player; 14 | import org.bukkit.event.Listener; 15 | import org.bukkit.event.entity.*; 16 | import org.bukkit.event.player.PlayerInteractAtEntityEvent; 17 | import org.bukkit.inventory.EquipmentSlot; 18 | import org.bukkit.persistence.PersistentDataContainer; 19 | import org.bukkit.persistence.PersistentDataType; 20 | import org.bukkit.plugin.PluginManager; 21 | import org.bukkit.plugin.java.JavaPlugin; 22 | import org.bukkit.util.Consumer; 23 | import org.jetbrains.annotations.NotNull; 24 | import org.jetbrains.annotations.Nullable; 25 | 26 | import java.util.Map; 27 | import java.util.concurrent.ConcurrentHashMap; 28 | 29 | /** 30 | * @param The type of {@link Entity} that will be the true, internal, server understood representation. 31 | */ 32 | public abstract class CustomEntityType { 33 | 34 | /** 35 | * The {@link JavaPlugin} retrieved through the ClassLoader. This is used to register the listeners and 36 | * for handling persistent data on the entities. 37 | */ 38 | private static JavaPlugin plugin = null; 39 | 40 | /** 41 | * The {@link NamespacedKey} used for interfacing with {@link PersistentDataContainer}. 42 | */ 43 | private static NamespacedKey entityKey = null; 44 | 45 | /** 46 | * The internal registry of {@link CustomEntityType}'s as a {@link ConcurrentHashMap} for safe multi-threaded access. 47 | */ 48 | @NotNull 49 | private static final Map> REGISTRY = new ConcurrentHashMap<>(); 50 | 51 | @NotNull 52 | private final String id; 53 | 54 | @Nullable 55 | private final Skin skin; 56 | 57 | @NotNull 58 | private final Class internalEntityClass; 59 | 60 | @NotNull 61 | private final EntityType internalType; 62 | 63 | @NotNull 64 | private final Class displayEntityClass; 65 | 66 | @NotNull 67 | private final EntityType displayType; 68 | 69 | /** 70 | * Neither the internal nor display can be non-spawnable entities.
71 | * You can verify if the entity you are using us spawnable by checking {@link EntityType}'s "independent" variable, it should be true/unset. 72 | * 73 | * @param id The ID to be used in the registry. 74 | * @param skin The Skin to use for the display, only works if the display entity is a {@link Player}. 75 | * @param internalEntityClass The {@link Class class} of the {@link Entity} type to be truly created on the server. 76 | * @param displayEntityClass The {@link Class class} of the {@link Entity} type to be sent to the client. 77 | */ 78 | public CustomEntityType( 79 | @NotNull String id, 80 | @Nullable Skin skin, 81 | @NotNull Class internalEntityClass, 82 | @NotNull Class displayEntityClass 83 | ) { 84 | this.id = id; 85 | this.skin = skin; 86 | this.internalEntityClass = internalEntityClass; 87 | this.displayEntityClass = displayEntityClass; 88 | 89 | EntityType internalType = getEntityType(internalEntityClass); 90 | 91 | if (internalType == null) { 92 | throw new IllegalArgumentException( 93 | "CustomEntityType[%s] Internal Class \"%s\" has no EntityType.".formatted( 94 | this.id, 95 | internalEntityClass 96 | ) 97 | ); 98 | } 99 | 100 | this.internalType = internalType; 101 | 102 | if (!this.internalType.isSpawnable()) { 103 | throw new IllegalArgumentException( 104 | "CustomEntityType[%s] Internal EntityType \"%s\" is not spawnable.".formatted( 105 | this.id, 106 | internalType 107 | ) 108 | ); 109 | } 110 | 111 | EntityType displayType = getEntityType(displayEntityClass); 112 | 113 | if (displayType == null) { 114 | throw new IllegalArgumentException( 115 | "CustomEntityType[%s] Display Class \"%s\" has no EntityType.".formatted( 116 | this.id, 117 | displayEntityClass 118 | ) 119 | ); 120 | } 121 | 122 | this.displayType = displayType; 123 | } 124 | 125 | /** 126 | * Neither the internal nor display can be non-spawnable entities.
127 | * You can verify if the entity you are using us spawnable by checking {@link EntityType}'s "independent" variable, it should be true/unset.

128 | * Defaults the {@link Skin} to null. 129 | * 130 | * @param id The ID to be used in the registry. 131 | * @param internalEntityClass The {@link Class class} of the {@link Entity} type to be truly created on the server. 132 | * @param displayEntityClass The {@link Class class} of the {@link Entity} type to be sent to the client. 133 | */ 134 | public CustomEntityType( 135 | @NotNull String id, 136 | @NotNull Class internalEntityClass, 137 | @NotNull Class displayEntityClass 138 | ) { 139 | this(id, null, internalEntityClass, displayEntityClass); 140 | } 141 | 142 | @NotNull 143 | public String getID() { 144 | return id; 145 | } 146 | 147 | @NotNull 148 | public EntityType getInternalType() { 149 | return internalType; 150 | } 151 | 152 | @NotNull 153 | public EntityType getDisplayType() { 154 | return displayType; 155 | } 156 | 157 | @NotNull 158 | public Class getInternalEntityClass() { 159 | return internalEntityClass; 160 | } 161 | 162 | @NotNull 163 | public Class getDisplayEntityClass() { 164 | return displayEntityClass; 165 | } 166 | 167 | @Nullable 168 | public Skin getSkin() { 169 | return skin; 170 | } 171 | 172 | /** 173 | * This will be called whenever this {@link CustomEntityType} is right-clicked by a {@link Player}.
174 | * Note: This will be called twice for each hand in the {@link PlayerInteractAtEntityEvent event}.

175 | * For most cases, it's fine to check if {@link PlayerInteractAtEntityEvent event}.getHand() == {@link EquipmentSlot EquipmentSlot.HAND}. 176 | * 177 | * @param entity The {@link T entity} that was right-clicked. 178 | * @param player The {@link Player} that right-clicked the entity. 179 | * @param event The {@link PlayerInteractAtEntityEvent event} instance. 180 | */ 181 | public void onRightClick(@NotNull T entity, @NotNull Player player, @NotNull PlayerInteractAtEntityEvent event) { 182 | 183 | } 184 | 185 | /** 186 | * This will be called whenever this {@link CustomEntityType} is damaged. 187 | * 188 | * @param entity The {@link T entity} that was damaged. 189 | * @param event The {@link EntityDamageEvent event} instance. 190 | */ 191 | public void onDamage(@NotNull T entity, @NotNull EntityDamageEvent event) { 192 | 193 | } 194 | 195 | /** 196 | * This will be called whenever this {@link CustomEntityType} deals damage to another {@link Entity}. 197 | * 198 | * @param entity The {@link T entity} that dealt damage. 199 | * @param damaged The {@link Entity entity} that was damaged. 200 | * @param event The {@link EntityDamageByEntityEvent event} instance. 201 | */ 202 | public void onDamageToEntity(@NotNull T entity, @NotNull Entity damaged, @NotNull EntityDamageByEntityEvent event) { 203 | 204 | } 205 | 206 | /** 207 | * This will be called whenever this {@link CustomEntityType} is damaged by another {@link Entity}. 208 | * 209 | * @param entity The {@link T entity} that was damaged. 210 | * @param damager the {@link Entity entity} that dealt damage. 211 | * @param event The {@link EntityDamageByEntityEvent event} instance. 212 | */ 213 | public void onDamageByEntity(@NotNull T entity, @NotNull Entity damager, @NotNull EntityDamageByEntityEvent event) { 214 | 215 | } 216 | 217 | /** 218 | * This will be called whenever this {@link CustomEntityType} gets damaged by a {@link Block}. 219 | * 220 | * @param entity The {@link T entity} that was damaged. 221 | * @param block The {@link Block} that damaged the entity. 222 | * @param event The {@link EntityDamageByBlockEvent event} instance. 223 | */ 224 | public void onDamageByBlock(@NotNull T entity, @Nullable Block block, @NotNull EntityDamageByBlockEvent event) { 225 | 226 | } 227 | 228 | /** 229 | * This will be called whenever this {@link CustomEntityType} dies. 230 | * 231 | * @param entity The {@link T entity} that died. 232 | * @param event The {@link EntityDeathEvent event} instance. 233 | */ 234 | public void onDeath(@NotNull T entity, @NotNull EntityDeathEvent event) { 235 | 236 | } 237 | 238 | /** 239 | * This will be called whenever this {@link CustomEntityType} is spawned. 240 | * 241 | * @param entity The {@link T Entity} that was spawned. 242 | */ 243 | public void onSpawn(@NotNull T entity) { 244 | 245 | } 246 | 247 | /** 248 | * This will be called whenever this {@link CustomEntityType} is spawned. 249 | * 250 | * @param entity - The {@link Entity} that was spawned. 251 | */ 252 | public void onPreSpawn(@NotNull T entity) { 253 | 254 | } 255 | 256 | /** 257 | * Spawns the {@link CustomEntityType} at the given {@link Location}, and allowing for pre-spawn {@link Consumer} to be passed. 258 | * 259 | * @param location The {@link Location} to spawn the {@link T entity} at. 260 | * @param preSpawnFunction Passed to the world.spawn() function that gets executed before the {@link Entity} is in the world. 261 | * @return The {@link T entity}. 262 | */ 263 | @NotNull 264 | public final T spawn(Location location, Consumer preSpawnFunction) { 265 | World world = location.getWorld(); 266 | T entity = world.spawn(location, this.internalEntityClass, CreatureSpawnEvent.SpawnReason.CUSTOM, (preEntity) -> { 267 | CustomEntityType.set(preEntity, this); 268 | preSpawnFunction.accept(preEntity); 269 | onPreSpawn(preEntity); 270 | }); 271 | this.onSpawn(entity); 272 | return entity; 273 | } 274 | 275 | /** 276 | * Spawns the {@link CustomEntityType} at the given {@link Location}. 277 | * 278 | * @param location The{@link Location} to spawn the {@link T entity} at. 279 | * @return The {@link T entity}. 280 | */ 281 | @NotNull 282 | public final T spawn(Location location) { 283 | return this.spawn(location, (entity) -> { 284 | }); 285 | } 286 | 287 | /** 288 | * A dynamic spawn function for when you just want to spawn an entity that looks like another entity on the client.

289 | * Note: entities spawned with this function will NOT be persistent!
290 | * This is an experimental functionality, use at your own risk. 291 | * 292 | * @param internal The internal {@link EntityType} to use for this {@link Entity}. 293 | * @param display The display {@link EntityType} that clients will see. 294 | * @param location The spawn {@link Location} for the entity. 295 | * @return The {@link Entity} spawned entity, or null if either the display or internal don't have an entity class. 296 | */ 297 | public static Entity spawn(EntityType internal, EntityType display, Location location) { 298 | String dynamicID = internal.name() + "-" + display.name(); 299 | 300 | if (internal.getEntityClass() == null) { 301 | return null; 302 | } 303 | 304 | if (display.getEntityClass() == null) { 305 | return null; 306 | } 307 | 308 | CustomEntityType type = new CustomEntityType(dynamicID, internal.getEntityClass(), display.getEntityClass()) { 309 | }; 310 | 311 | register(type); 312 | 313 | return location.getWorld().spawn(location, internal.getEntityClass(), (entity) -> { 314 | set(entity, type); 315 | }); 316 | } 317 | 318 | /** 319 | * This will be invoked when register() is called and the {@link JavaPlugin plugin} is not initialized. 320 | * This is responsible for registering the events and constructing the key, this solely exists this way 321 | * to allow for static initialization of {@link CustomEntityType} outside of onEnable. 322 | */ 323 | private static void initialize() { 324 | plugin = JavaPlugin.getProvidingPlugin(CustomEntityType.class); 325 | entityKey = new NamespacedKey(plugin, "custom_entity"); 326 | PluginManager pluginManager = Bukkit.getPluginManager(); 327 | pluginManager.registerEvents(new PlayerPacketListener(plugin), plugin); 328 | pluginManager.registerEvents(new CustomEntityListener(), plugin); 329 | } 330 | 331 | /** 332 | * This will register the {@link CustomEntityType} and MUST be called in the onEnable of your plugin before 333 | * you do anything else. This is also responsible for proper initialization of the {@link Listener listeners}. 334 | * 335 | * @param type The {@link CustomEntityType} to register. 336 | * @return The {@link CustomEntityType} that was overridden, if it exists, due to duplicate ID's. 337 | */ 338 | @Nullable 339 | public static CustomEntityType register(CustomEntityType type) { 340 | if (plugin == null) { 341 | initialize(); 342 | } 343 | 344 | return REGISTRY.put(type.getID(), type); 345 | } 346 | 347 | /** 348 | * Get the registered {@link CustomEntityType} for the given {@link String} ID. 349 | * 350 | * @param id The {@link String} ID of the {@link CustomEntityType} to get from the registry. 351 | * @return The {@link CustomEntityType} for the given ID, or null if it doesn't exist. 352 | */ 353 | @Nullable 354 | public static CustomEntityType get(String id) { 355 | return REGISTRY.get(id); 356 | } 357 | 358 | /** 359 | * A convenience method for getting the {@link CustomEntityType} from the {@link Entity} metadata. 360 | * 361 | * @param entity the {@link Entity} to get the {@link CustomEntityType} from; 362 | * @return The proper {@link CustomEntityType}, or null if it doesn't have one, or the ID is invalid. 363 | */ 364 | @Nullable 365 | public static CustomEntityType get(Entity entity) { 366 | PersistentDataContainer data = entity.getPersistentDataContainer(); 367 | if (!data.has(entityKey, PersistentDataType.STRING)) { 368 | return null; 369 | } 370 | 371 | String customEntityID = data.get(entityKey, PersistentDataType.STRING); 372 | 373 | //TODO: Perhaps pass a boolean for whether this should throw an exception or something when this is returning null? 374 | return REGISTRY.get(customEntityID); 375 | } 376 | 377 | /** 378 | * A convenience method for giving an {@link Entity} a {@link CustomEntityType}. 379 | * 380 | * @param entity The {@link Entity} to receive the {@link CustomEntityType}. 381 | * @param customEntityType The {@link CustomEntityType} to give the {@link Entity}. 382 | */ 383 | public static void set(Entity entity, CustomEntityType customEntityType) { 384 | PersistentDataContainer data = entity.getPersistentDataContainer(); 385 | data.set(entityKey, PersistentDataType.STRING, customEntityType.getID()); 386 | } 387 | 388 | /** 389 | * Convenience function for getting the {@link EntityType} from a {@link Class}. 390 | * 391 | * @param clazz The {@link Class} to find the {@link EntityType} for. 392 | * @return The appropriate {@link EntityType}, or null if not found. 393 | */ 394 | @Nullable 395 | private static EntityType getEntityType(Class clazz) { 396 | for (EntityType entityType : EntityType.values()) { 397 | if (entityType.getEntityClass() != clazz) { 398 | continue; 399 | } 400 | 401 | return entityType; 402 | } 403 | 404 | return null; 405 | } 406 | } 407 | --------------------------------------------------------------------------------