(pending.keySet());
+ for (SkinnableEntity entity : entities) {
+ applyAndRespawn(entity);
+ }
+ pending.clear();
+ }
+
+ /**
+ * Clear all cached skins.
+ */
+ public static void clearCache() {
+ synchronized (CACHE) {
+ for (Skin skin : CACHE.values()) {
+ skin.pending.clear();
+ if (skin.retryTask != null) {
+ skin.retryTask.cancel();
+ }
+ }
+ CACHE.clear();
+ }
+ }
+
+ /**
+ * Get a skin for a skinnable entity.
+ *
+ *
+ * If a Skin instance does not exist, a new one is created and the skin data is automatically fetched.
+ *
+ *
+ * @param entity
+ * The skinnable entity.
+ */
+ public static Skin get(SkinnableEntity entity) {
+ return get(entity, false);
+ }
+
+ /**
+ * Get a skin for a skinnable entity.
+ *
+ *
+ * If a Skin instance does not exist, a new one is created and the skin data is automatically fetched.
+ *
+ *
+ * @param entity
+ * The skinnable entity.
+ * @param forceUpdate
+ * if the skin should be checked via the cache
+ */
+ public static Skin get(SkinnableEntity entity, boolean forceUpdate) {
+ Preconditions.checkNotNull(entity);
+
+ String skinName = entity.getSkinName().toLowerCase();
+ return get(skinName, forceUpdate);
+ }
+
+ /**
+ * Get a player skin.
+ *
+ *
+ * If a Skin instance does not exist, a new one is created and the skin data is automatically fetched.
+ *
+ *
+ * @param skinName
+ * The name of the skin.
+ */
+ public static Skin get(String skinName, boolean forceUpdate) {
+ Preconditions.checkNotNull(skinName);
+
+ skinName = skinName.toLowerCase();
+
+ Skin skin;
+ synchronized (CACHE) {
+ skin = CACHE.get(skinName);
+ }
+
+ if (skin == null) {
+ skin = new Skin(skinName);
+ } else if (forceUpdate) {
+ skin.fetch();
+ }
+
+ return skin;
+ }
+
+ private static void setNPCSkinData(SkinnableEntity entity, String skinName, UUID skinId, Property skinProperty) {
+ NPC npc = entity.getNPC();
+
+ // cache skins for faster initial skin availability and
+ // for use when the latest skin is not required.
+ npc.data().setPersistent(CACHED_SKIN_UUID_NAME_METADATA, skinName);
+ npc.data().setPersistent(CACHED_SKIN_UUID_METADATA, skinId.toString());
+ if (skinProperty.getValue() != null) {
+ npc.data().setPersistent(NPC.PLAYER_SKIN_TEXTURE_PROPERTIES_METADATA, skinProperty.getValue());
+ npc.data().setPersistent(NPC.PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN_METADATA, skinProperty.getSignature());
+ setNPCTexture(entity, skinProperty);
+ } else {
+ npc.data().remove(NPC.PLAYER_SKIN_TEXTURE_PROPERTIES_METADATA);
+ npc.data().remove(NPC.PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN_METADATA);
+ }
+ }
+
+ private static void setNPCTexture(SkinnableEntity entity, Property skinProperty) {
+ GameProfile profile = entity.getProfile();
+
+ // don't set property if already set since this sometimes causes
+ // packet errors that disconnect the client.
+ Property current = Iterables.getFirst(profile.getProperties().get("textures"), null);
+ if (current != null && current.getValue().equals(skinProperty.getValue())
+ && (current.getSignature() != null && current.getSignature().equals(skinProperty.getSignature()))) {
+ return;
+ }
+
+ profile.getProperties().removeAll("textures"); // ensure client does not crash due to duplicate properties.
+ profile.getProperties().put("textures", skinProperty);
+ }
+
+ private static final Map CACHE = new HashMap(20);
+ public static final String CACHED_SKIN_UUID_METADATA = "cached-skin-uuid";
+ public static final String CACHED_SKIN_UUID_NAME_METADATA = "cached-skin-uuid-name";
+}
diff --git a/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java b/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java
new file mode 100644
index 000000000..9799c5412
--- /dev/null
+++ b/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java
@@ -0,0 +1,255 @@
+package net.citizensnpcs.npc.skin;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.scheduler.BukkitRunnable;
+import org.bukkit.scheduler.BukkitTask;
+
+import com.google.common.base.Preconditions;
+
+import net.citizensnpcs.Settings;
+import net.citizensnpcs.api.CitizensAPI;
+import net.citizensnpcs.util.NMS;
+
+/**
+ * Handles and synchronizes add and remove packets for Player type NPC's in order to properly apply the NPC skin.
+ *
+ *
+ * Used as one instance per NPC entity.
+ *
+ */
+public class SkinPacketTracker {
+ private final SkinnableEntity entity;
+ private final Map inProgress = new HashMap(Bukkit.getMaxPlayers() / 2);
+
+ private boolean isRemoved;
+ private Skin skin;
+
+ /**
+ * Constructor.
+ *
+ * @param entity
+ * The skinnable entity the instance belongs to.
+ */
+ public SkinPacketTracker(SkinnableEntity entity) {
+ Preconditions.checkNotNull(entity);
+
+ this.entity = entity;
+ this.skin = Skin.get(entity);
+
+ if (LISTENER == null) {
+ LISTENER = new PlayerListener();
+ Bukkit.getPluginManager().registerEvents(LISTENER, CitizensAPI.getPlugin());
+ }
+ }
+
+ /**
+ * Get the NPC skin.
+ */
+ public Skin getSkin() {
+ return skin;
+ }
+
+ /**
+ * Notify the tracker that a remove packet has been sent to the specified player.
+ *
+ * @param playerId
+ * The ID of the player.
+ */
+ void notifyRemovePacketCancelled(UUID playerId) {
+ inProgress.remove(playerId);
+ }
+
+ /**
+ * Notify the tracker that a remove packet has been sent to the specified player.
+ *
+ * @param playerId
+ * The ID of the player.
+ */
+ void notifyRemovePacketSent(UUID playerId) {
+ PlayerEntry entry = inProgress.get(playerId);
+ if (entry == null)
+ return;
+
+ if (entry.removeCount == 0)
+ return;
+
+ entry.removeCount -= 1;
+ if (entry.removeCount == 0) {
+ inProgress.remove(playerId);
+ } else {
+ scheduleRemovePacket(entry);
+ }
+ }
+
+ /**
+ * Notify that the NPC skin has been changed.
+ */
+ public void notifySkinChange(boolean forceUpdate) {
+ this.skin = Skin.get(entity, forceUpdate);
+ skin.applyAndRespawn(entity);
+ }
+
+ /**
+ * Invoke when the NPC entity is removed.
+ *
+ *
+ * Sends remove packets to all players.
+ *
+ */
+ public void onRemoveNPC() {
+ isRemoved = true;
+
+ Collection extends Player> players = Bukkit.getOnlinePlayers();
+
+ for (Player player : players) {
+
+ if (player.hasMetadata("NPC"))
+ continue;
+
+ // send packet now and later to ensure removal from player list
+ NMS.sendTabListRemove(player, entity.getBukkitEntity());
+ TAB_LIST_REMOVER.sendPacket(player, entity);
+ }
+ }
+
+ /**
+ * Invoke when the NPC entity is spawned.
+ */
+ public void onSpawnNPC() {
+ isRemoved = false;
+ new BukkitRunnable() {
+ @Override
+ public void run() {
+ if (!entity.getNPC().isSpawned())
+ return;
+
+ double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble();
+ updateNearbyViewers(viewDistance);
+ }
+ }.runTaskLater(CitizensAPI.getPlugin(), 20);
+ }
+
+ private void scheduleRemovePacket(final PlayerEntry entry) {
+ if (isRemoved)
+ return;
+
+ entry.removeTask = Bukkit.getScheduler().runTaskLater(CitizensAPI.getPlugin(), new Runnable() {
+ @Override
+ public void run() {
+ if (shouldRemoveFromTabList()) {
+ TAB_LIST_REMOVER.sendPacket(entry.player, entity);
+ }
+ }
+ }, PACKET_DELAY_REMOVE);
+ }
+
+ private void scheduleRemovePacket(PlayerEntry entry, int count) {
+ if (!shouldRemoveFromTabList())
+ return;
+
+ entry.removeCount = count;
+ scheduleRemovePacket(entry);
+ }
+
+ private boolean shouldRemoveFromTabList() {
+ return entity.getNPC().data().get("removefromtablist", Settings.Setting.DISABLE_TABLIST.asBoolean());
+ }
+
+ /**
+ * Send skin related packets to all nearby players within the specified block radius.
+ *
+ * @param radius
+ * The radius.
+ */
+ public void updateNearbyViewers(double radius) {
+ radius *= radius;
+
+ org.bukkit.World world = entity.getBukkitEntity().getWorld();
+ Player from = entity.getBukkitEntity();
+ Location location = from.getLocation();
+
+ for (Player player : world.getPlayers()) {
+ if (player == null || player.hasMetadata("NPC"))
+ continue;
+
+ player.getLocation(CACHE_LOCATION);
+ if (!player.canSee(from) || !location.getWorld().equals(CACHE_LOCATION.getWorld()))
+ continue;
+
+ if (location.distanceSquared(CACHE_LOCATION) > radius)
+ continue;
+
+ updateViewer(player);
+ }
+ }
+
+ /**
+ * Send skin related packets to a player.
+ *
+ * @param player
+ * The player.
+ */
+ public void updateViewer(final Player player) {
+ Preconditions.checkNotNull(player);
+
+ if (isRemoved || player.hasMetadata("NPC"))
+ return;
+
+ PlayerEntry entry = inProgress.get(player.getUniqueId());
+ if (entry != null) {
+ entry.cancel();
+ } else {
+ entry = new PlayerEntry(player);
+ }
+
+ TAB_LIST_REMOVER.cancelPackets(player, entity);
+
+ inProgress.put(player.getUniqueId(), entry);
+ skin.apply(entity);
+ NMS.sendTabListAdd(player, entity.getBukkitEntity());
+
+ scheduleRemovePacket(entry, 2);
+ }
+
+ private class PlayerEntry {
+ Player player;
+ int removeCount;
+ BukkitTask removeTask;
+
+ PlayerEntry(Player player) {
+ this.player = player;
+ }
+
+ // cancel previous packet tasks so they do not interfere with
+ // new tasks
+ void cancel() {
+ if (removeTask != null)
+ removeTask.cancel();
+ removeCount = 0;
+ }
+ }
+
+ private static class PlayerListener implements Listener {
+ @EventHandler
+ private void onPlayerQuit(PlayerQuitEvent event) {
+ // this also causes any entries in the "inProgress" field to
+ // be removed.
+ TAB_LIST_REMOVER.cancelPackets(event.getPlayer());
+ }
+ }
+
+ private static final Location CACHE_LOCATION = new Location(null, 0, 0, 0);
+ private static PlayerListener LISTENER;
+ private static final int PACKET_DELAY_REMOVE = 1;
+ private static final TabListRemover TAB_LIST_REMOVER = new TabListRemover();
+}
diff --git a/main/java/net/citizensnpcs/npc/skin/SkinUpdateTracker.java b/main/java/net/citizensnpcs/npc/skin/SkinUpdateTracker.java
new file mode 100644
index 000000000..a06814ef8
--- /dev/null
+++ b/main/java/net/citizensnpcs/npc/skin/SkinUpdateTracker.java
@@ -0,0 +1,481 @@
+package net.citizensnpcs.npc.skin;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
+import java.util.WeakHashMap;
+
+import javax.annotation.Nullable;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.scheduler.BukkitRunnable;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicates;
+import com.google.common.collect.Iterables;
+
+import net.citizensnpcs.Settings;
+import net.citizensnpcs.api.CitizensAPI;
+import net.citizensnpcs.api.npc.NPC;
+import net.citizensnpcs.api.npc.NPCRegistry;
+import net.citizensnpcs.util.Util;
+
+/**
+ * Tracks skin updates for players.
+ *
+ * @see net.citizensnpcs.EventListen
+ */
+public class SkinUpdateTracker {
+
+ private final Map navigating = new WeakHashMap(25);
+ private final NPCRegistry npcRegistry;
+ private final Map playerTrackers = new HashMap(
+ Bukkit.getMaxPlayers() / 2);
+ private final Map registries;
+ private final NPCNavigationUpdater updater = new NPCNavigationUpdater();
+
+ /**
+ * Constructor.
+ *
+ * @param npcRegistry
+ * The primary citizens registry.
+ * @param registries
+ * Map of other registries.
+ */
+ public SkinUpdateTracker(NPCRegistry npcRegistry, Map registries) {
+ Preconditions.checkNotNull(npcRegistry);
+ Preconditions.checkNotNull(registries);
+
+ this.npcRegistry = npcRegistry;
+ this.registries = registries;
+
+ updater.runTaskTimer(CitizensAPI.getPlugin(), 1, 1);
+ new NPCNavigationTracker().runTaskTimer(CitizensAPI.getPlugin(), 3, 7);
+ }
+
+ // determines if a player is near a skinnable entity and, if checkFov set, if the
+ // skinnable entity is within the players field of view.
+ private boolean canSee(Player player, SkinnableEntity skinnable, boolean checkFov) {
+ Player entity = skinnable.getBukkitEntity();
+ if (entity == null)
+ return false;
+
+ if (!player.canSee(entity))
+ return false;
+
+ if (!player.getWorld().equals(entity.getWorld()))
+ return false;
+
+ Location playerLoc = player.getLocation(CACHE_LOCATION);
+ Location skinLoc = entity.getLocation(NPC_LOCATION);
+
+ double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble();
+ viewDistance *= viewDistance;
+
+ if (playerLoc.distanceSquared(skinLoc) > viewDistance)
+ return false;
+
+ // see if the NPC is within the players field of view
+ if (checkFov) {
+ double deltaX = skinLoc.getX() - playerLoc.getX();
+ double deltaZ = skinLoc.getZ() - playerLoc.getZ();
+ double angle = Math.atan2(deltaX, deltaZ);
+ float skinYaw = Util.clampYaw(-(float) Math.toDegrees(angle));
+ float playerYaw = Util.clampYaw(playerLoc.getYaw());
+ float upperBound = Util.clampYaw(playerYaw + FIELD_OF_VIEW);
+ float lowerBound = Util.clampYaw(playerYaw - FIELD_OF_VIEW);
+ if (upperBound == -180.0 && playerYaw > 0) {
+ upperBound = 0;
+ }
+ boolean hasMoved;
+ if (playerYaw - 90 < -180 || playerYaw + 90 > 180) {
+ hasMoved = skinYaw > lowerBound && skinYaw < upperBound;
+ } else {
+ hasMoved = skinYaw < lowerBound || skinYaw > upperBound;
+ }
+ return hasMoved;
+ }
+
+ return true;
+ }
+
+ private Iterable getAllNPCs() {
+ return Iterables.filter(Iterables.concat(npcRegistry, Iterables.concat(registries.values())),
+ Predicates.notNull());
+ }
+
+ private List getNearbyNPCs(Player player, boolean reset, boolean checkFov) {
+ List results = new ArrayList();
+ PlayerTracker tracker = getTracker(player, reset);
+ for (NPC npc : getAllNPCs()) {
+
+ SkinnableEntity skinnable = getSkinnable(npc);
+ if (skinnable == null)
+ continue;
+
+ // if checking field of view, don't add skins that have already been updated for FOV
+ if (checkFov && tracker.fovVisibleSkins.contains(skinnable))
+ continue;
+
+ if (canSee(player, skinnable, checkFov)) {
+ results.add(skinnable);
+ }
+ }
+ return results;
+ }
+
+ // get all navigating skinnable NPC's within the players FOV that have not been "seen" yet
+ private void getNewVisibleNavigating(Player player, Collection output) {
+ PlayerTracker tracker = getTracker(player, false);
+
+ for (SkinnableEntity skinnable : navigating.keySet()) {
+
+ // make sure player hasn't already been updated to prevent excessive tab list flashing
+ // while NPC's are navigating and to reduce the number of times #canSee is invoked.
+ if (tracker.fovVisibleSkins.contains(skinnable))
+ continue;
+
+ if (canSee(player, skinnable, true))
+ output.add(skinnable);
+ }
+ }
+
+ @Nullable
+ private SkinnableEntity getSkinnable(NPC npc) {
+ Entity entity = npc.getEntity();
+ if (entity == null)
+ return null;
+
+ return entity instanceof SkinnableEntity ? (SkinnableEntity) entity : null;
+ }
+
+ // get a players tracker, create new one if not exists.
+ private PlayerTracker getTracker(Player player, boolean reset) {
+ PlayerTracker tracker = playerTrackers.get(player.getUniqueId());
+ if (tracker == null) {
+ tracker = new PlayerTracker(player);
+ playerTrackers.put(player.getUniqueId(), tracker);
+ } else if (reset) {
+ tracker.hardReset(player);
+ }
+ return tracker;
+ }
+
+ /**
+ * Invoke when an NPC is despawned.
+ *
+ * @param npc
+ * The despawned NPC.
+ */
+ public void onNPCDespawn(NPC npc) {
+ Preconditions.checkNotNull(npc);
+ SkinnableEntity skinnable = getSkinnable(npc);
+ if (skinnable == null)
+ return;
+
+ navigating.remove(skinnable);
+
+ for (PlayerTracker tracker : playerTrackers.values()) {
+ tracker.fovVisibleSkins.remove(skinnable);
+ }
+ }
+
+ /**
+ * Invoke when an NPC begins navigating.
+ *
+ * @param npc
+ * The navigating NPC.
+ */
+ public void onNPCNavigationBegin(NPC npc) {
+ Preconditions.checkNotNull(npc);
+ SkinnableEntity skinnable = getSkinnable(npc);
+ if (skinnable == null)
+ return;
+
+ navigating.put(skinnable, null);
+ }
+
+ /**
+ * Invoke when an NPC finishes navigating.
+ *
+ * @param npc
+ * The finished NPC.
+ */
+ public void onNPCNavigationComplete(NPC npc) {
+ Preconditions.checkNotNull(npc);
+ SkinnableEntity skinnable = getSkinnable(npc);
+ if (skinnable == null)
+ return;
+
+ navigating.remove(skinnable);
+ }
+
+ /**
+ * Invoke when an NPC is spawned.
+ *
+ * @param npc
+ * The spawned NPC.
+ */
+ public void onNPCSpawn(NPC npc) {
+ Preconditions.checkNotNull(npc);
+ SkinnableEntity skinnable = getSkinnable(npc);
+ if (skinnable == null)
+ return;
+
+ // reset nearby players in case they are not looking at the NPC when it spawns.
+ resetNearbyPlayers(skinnable);
+ }
+
+ /**
+ * Invoke when a player moves.
+ *
+ * @param player
+ * The player that moved.
+ */
+ public void onPlayerMove(Player player) {
+ Preconditions.checkNotNull(player);
+ PlayerTracker updateTracker = playerTrackers.get(player.getUniqueId());
+ if (updateTracker == null)
+ return;
+
+ if (!updateTracker.shouldUpdate(player))
+ return;
+
+ updatePlayer(player, 10, false);
+ }
+
+ /**
+ * Remove a player from the tracker.
+ *
+ *
+ * Used when the player logs out.
+ *
+ *
+ * @param playerId
+ * The ID of the player.
+ */
+ public void removePlayer(UUID playerId) {
+ Preconditions.checkNotNull(playerId);
+ playerTrackers.remove(playerId);
+ }
+
+ /**
+ * Reset all players currently being tracked.
+ *
+ *
+ * Used when Citizens is reloaded.
+ *
+ */
+ public void reset() {
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ if (player.hasMetadata("NPC"))
+ continue;
+
+ PlayerTracker tracker = playerTrackers.get(player.getUniqueId());
+ if (tracker == null)
+ continue;
+
+ tracker.hardReset(player);
+ }
+ }
+
+ // hard reset players near a skinnable NPC
+ private void resetNearbyPlayers(SkinnableEntity skinnable) {
+ Entity entity = skinnable.getBukkitEntity();
+ if (entity == null || !entity.isValid())
+ return;
+
+ double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble();
+ viewDistance *= viewDistance;
+ Location location = entity.getLocation(NPC_LOCATION);
+ List players = entity.getWorld().getPlayers();
+ for (Player player : players) {
+ if (player.hasMetadata("NPC"))
+ continue;
+ Location ploc = player.getLocation(CACHE_LOCATION);
+ if (ploc.getWorld() != location.getWorld())
+ continue;
+ double distanceSquared = ploc.distanceSquared(location);
+ if (distanceSquared > viewDistance)
+ continue;
+
+ PlayerTracker tracker = playerTrackers.get(player.getUniqueId());
+ if (tracker != null) {
+ tracker.hardReset(player);
+ }
+ }
+ }
+
+ /**
+ * Update a player with skin related packets from nearby skinnable NPC's.
+ *
+ * @param player
+ * The player to update.
+ * @param delay
+ * The delay before sending the packets.
+ * @param reset
+ * True to hard reset the players tracking info, otherwise false.
+ */
+ public void updatePlayer(final Player player, long delay, final boolean reset) {
+ if (player.hasMetadata("NPC"))
+ return;
+
+ new BukkitRunnable() {
+ @Override
+ public void run() {
+ List visible = getNearbyNPCs(player, reset, false);
+ for (SkinnableEntity skinnable : visible) {
+ skinnable.getSkinTracker().updateViewer(player);
+ }
+ }
+ }.runTaskLater(CitizensAPI.getPlugin(), delay);
+ }
+
+ // update players when the NPC navigates into their field of view
+ private class NPCNavigationTracker extends BukkitRunnable {
+ @Override
+ public void run() {
+ if (navigating.isEmpty() || playerTrackers.isEmpty())
+ return;
+
+ List nearby = new ArrayList(10);
+ Collection extends Player> players = Bukkit.getOnlinePlayers();
+
+ for (Player player : players) {
+ if (player.hasMetadata("NPC"))
+ continue;
+
+ getNewVisibleNavigating(player, nearby);
+
+ for (SkinnableEntity skinnable : nearby) {
+ PlayerTracker tracker = getTracker(player, false);
+ tracker.fovVisibleSkins.add(skinnable);
+ updater.queue.offer(new UpdateInfo(player, skinnable));
+ }
+
+ nearby.clear();
+ }
+ }
+ }
+
+ // Updates players. Repeating task used to schedule updates without
+ // causing excessive scheduling.
+ private class NPCNavigationUpdater extends BukkitRunnable {
+ Queue queue = new ArrayDeque(20);
+
+ @Override
+ public void run() {
+ while (!queue.isEmpty()) {
+ UpdateInfo info = queue.remove();
+ info.entity.getSkinTracker().updateViewer(info.player);
+ }
+ }
+ }
+
+ // Tracks player location and yaw to determine when the player should be updated
+ // with nearby skins.
+ private class PlayerTracker {
+ final Set fovVisibleSkins = new HashSet(20);
+ boolean hasMoved;
+ final Location location = new Location(null, 0, 0, 0);
+ float lowerBound;
+ int rotationCount;
+ float startYaw;
+ float upperBound;
+
+ PlayerTracker(Player player) {
+ hardReset(player);
+ }
+
+ // reset all
+ void hardReset(Player player) {
+ this.hasMoved = false;
+ this.rotationCount = 0;
+ this.lowerBound = this.upperBound = this.startYaw = 0;
+ this.fovVisibleSkins.clear();
+ reset(player);
+ }
+
+ // resets initial yaw and location to the players current location and yaw.
+ void reset(Player player) {
+ player.getLocation(this.location);
+ if (rotationCount < 3) {
+ float rotationDegrees = Settings.Setting.NPC_SKIN_ROTATION_UPDATE_DEGREES.asFloat();
+ float yaw = Util.clampYaw(this.location.getYaw());
+ this.startYaw = yaw;
+ this.upperBound = Util.clampYaw(yaw + rotationDegrees);
+ this.lowerBound = Util.clampYaw(yaw - rotationDegrees);
+ if (upperBound == -180.0 && startYaw > 0) {
+ upperBound = 0;
+ }
+ }
+ }
+
+ boolean shouldUpdate(Player player) {
+ Location currentLoc = player.getLocation(CACHE_LOCATION);
+
+ if (!hasMoved) {
+ hasMoved = true;
+ return true;
+ }
+
+ if (rotationCount < 3) {
+ float yaw = Util.clampYaw(currentLoc.getYaw());
+ boolean hasRotated;
+ if (startYaw - 90 < -180 || startYaw + 90 > 180) {
+ hasRotated = yaw > lowerBound && yaw < upperBound;
+ } else {
+ hasRotated = yaw < lowerBound || yaw > upperBound;
+ }
+
+ // update the first 3 times the player rotates. helps load skins around player
+ // after the player logs/teleports.
+ if (hasRotated) {
+ rotationCount++;
+ reset(player);
+ return true;
+ }
+ }
+
+ // make sure player is in same world
+ if (!currentLoc.getWorld().equals(this.location.getWorld())) {
+ reset(player);
+ return true;
+ }
+
+ // update every time a player moves a certain distance
+ double distance = currentLoc.distanceSquared(this.location);
+ if (distance > MOVEMENT_SKIN_UPDATE_DISTANCE) {
+ reset(player);
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+
+ private static class UpdateInfo {
+ SkinnableEntity entity;
+ Player player;
+
+ UpdateInfo(Player player, SkinnableEntity entity) {
+ this.player = player;
+ this.entity = entity;
+ }
+ }
+
+ private static final Location CACHE_LOCATION = new Location(null, 0, 0, 0);
+ private static final float FIELD_OF_VIEW = 70f;
+ private static final int MOVEMENT_SKIN_UPDATE_DISTANCE = 50 * 50;
+ private static final Location NPC_LOCATION = new Location(null, 0, 0, 0);
+}
diff --git a/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java b/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java
new file mode 100644
index 000000000..6fd749b00
--- /dev/null
+++ b/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java
@@ -0,0 +1,59 @@
+package net.citizensnpcs.npc.skin;
+
+import org.bukkit.entity.Player;
+
+import com.mojang.authlib.GameProfile;
+
+import net.citizensnpcs.npc.ai.NPCHolder;
+
+/**
+ * Interface for player entities that are skinnable.
+ */
+public interface SkinnableEntity extends NPCHolder {
+
+ /**
+ * Get the bukkit entity.
+ */
+ Player getBukkitEntity();
+
+ /**
+ * Get entity game profile.
+ */
+ GameProfile getProfile();
+
+ /**
+ * Get the name of the player whose skin the NPC uses.
+ */
+ String getSkinName();
+
+ /**
+ * Get the entities skin packet tracker.
+ */
+ SkinPacketTracker getSkinTracker();
+
+ /**
+ * Set the bit flags that represent the skin layer parts visibility.
+ *
+ *
+ * Setting the skin flags automatically updates the NPC skin.
+ *
+ *
+ * @param flags
+ * The bit flags.
+ */
+ void setSkinFlags(byte flags);
+
+ /**
+ * Set the name of the player whose skin the NPC uses.
+ *
+ *
+ * Setting the skin name automatically updates and respawn the NPC.
+ *
+ *
+ * @param name
+ * The skin name.
+ */
+ void setSkinName(String name);
+
+ void setSkinName(String skinName, boolean forceUpdate);
+}
diff --git a/main/java/net/citizensnpcs/npc/skin/TabListRemover.java b/main/java/net/citizensnpcs/npc/skin/TabListRemover.java
new file mode 100644
index 000000000..7a0b6b891
--- /dev/null
+++ b/main/java/net/citizensnpcs/npc/skin/TabListRemover.java
@@ -0,0 +1,158 @@
+package net.citizensnpcs.npc.skin;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import com.google.common.base.Preconditions;
+
+import net.citizensnpcs.Settings;
+import net.citizensnpcs.api.CitizensAPI;
+import net.citizensnpcs.util.NMS;
+
+/**
+ * Sends remove packets in batch per player.
+ *
+ *
+ * Collects entities to remove and sends them all to the player in a single packet.
+ *
+ */
+public class TabListRemover {
+ private final Map pending = new HashMap(Bukkit.getMaxPlayers() / 2);
+
+ TabListRemover() {
+ Bukkit.getScheduler().runTaskTimer(CitizensAPI.getPlugin(), new Sender(), 2, 2);
+ }
+
+ /**
+ * Cancel packets pending to be sent to the specified player.
+ *
+ * @param player
+ * The player.
+ */
+ public void cancelPackets(Player player) {
+ Preconditions.checkNotNull(player);
+
+ PlayerEntry entry = pending.remove(player.getUniqueId());
+ if (entry == null)
+ return;
+
+ for (SkinnableEntity entity : entry.toRemove) {
+ entity.getSkinTracker().notifyRemovePacketCancelled(player.getUniqueId());
+ }
+ }
+
+ /**
+ * Cancel packets pending to be sent to the specified player for the specified skinnable entity.
+ *
+ * @param player
+ * The player.
+ * @param skinnable
+ * The skinnable entity.
+ */
+ public void cancelPackets(Player player, SkinnableEntity skinnable) {
+ Preconditions.checkNotNull(player);
+ Preconditions.checkNotNull(skinnable);
+
+ PlayerEntry entry = pending.get(player.getUniqueId());
+ if (entry == null)
+ return;
+
+ if (entry.toRemove.remove(skinnable)) {
+ skinnable.getSkinTracker().notifyRemovePacketCancelled(player.getUniqueId());
+ }
+
+ if (entry.toRemove.isEmpty())
+ pending.remove(player.getUniqueId());
+ }
+
+ private PlayerEntry getEntry(Player player) {
+
+ PlayerEntry entry = pending.get(player.getUniqueId());
+ if (entry == null) {
+ entry = new PlayerEntry(player);
+ pending.put(player.getUniqueId(), entry);
+ }
+
+ return entry;
+ }
+
+ /**
+ * Send a remove packet to the specified player for the specified skinnable entity.
+ *
+ * @param player
+ * The player to send the packet to.
+ * @param entity
+ * The entity to remove.
+ */
+ public void sendPacket(Player player, SkinnableEntity entity) {
+ Preconditions.checkNotNull(player);
+ Preconditions.checkNotNull(entity);
+
+ PlayerEntry entry = getEntry(player);
+
+ entry.toRemove.add(entity);
+ }
+
+ private class PlayerEntry {
+ Player player;
+ Set toRemove = new HashSet(25);
+
+ PlayerEntry(Player player) {
+ this.player = player;
+ }
+ }
+
+ private class Sender implements Runnable {
+ @Override
+ public void run() {
+
+ int maxPacketEntries = Settings.Setting.MAX_PACKET_ENTRIES.asInt();
+
+ Iterator> entryIterator = pending.entrySet().iterator();
+ while (entryIterator.hasNext()) {
+
+ Map.Entry mapEntry = entryIterator.next();
+ PlayerEntry entry = mapEntry.getValue();
+
+ int listSize = Math.min(maxPacketEntries, entry.toRemove.size());
+ boolean sendAll = listSize == entry.toRemove.size();
+
+ List skinnableList = new ArrayList(listSize);
+
+ int i = 0;
+ Iterator skinIterator = entry.toRemove.iterator();
+ while (skinIterator.hasNext()) {
+
+ if (i >= maxPacketEntries)
+ break;
+
+ SkinnableEntity skinnable = skinIterator.next();
+ skinnableList.add(skinnable);
+
+ skinIterator.remove();
+ i++;
+ }
+
+ if (entry.player.isOnline())
+ NMS.sendTabListRemove(entry.player, skinnableList);
+
+ // notify skin trackers that a remove packet has been sent to a player
+ for (SkinnableEntity entity : skinnableList) {
+ entity.getSkinTracker().notifyRemovePacketSent(entry.player.getUniqueId());
+ }
+
+ if (sendAll)
+ entryIterator.remove();
+ }
+ }
+ }
+}
diff --git a/main/java/net/citizensnpcs/trait/Age.java b/main/java/net/citizensnpcs/trait/Age.java
new file mode 100644
index 000000000..85ac11f05
--- /dev/null
+++ b/main/java/net/citizensnpcs/trait/Age.java
@@ -0,0 +1,69 @@
+package net.citizensnpcs.trait;
+
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Ageable;
+
+import net.citizensnpcs.api.persistence.Persist;
+import net.citizensnpcs.api.trait.Trait;
+import net.citizensnpcs.api.trait.TraitName;
+import net.citizensnpcs.api.util.Messaging;
+import net.citizensnpcs.util.Messages;
+
+@TraitName("age")
+public class Age extends Trait implements Toggleable {
+ @Persist
+ private int age = 0;
+ private Ageable ageable;
+ @Persist
+ private boolean locked = true;
+
+ public Age() {
+ super("age");
+ }
+
+ public void describe(CommandSender sender) {
+ Messaging.sendTr(sender, Messages.AGE_TRAIT_DESCRIPTION, npc.getName(), age, locked);
+ }
+
+ private boolean isAgeable() {
+ return ageable != null;
+ }
+
+ @Override
+ public void onSpawn() {
+ if (npc.getEntity() instanceof Ageable) {
+ Ageable entity = (Ageable) npc.getEntity();
+ entity.setAge(age);
+ entity.setAgeLock(locked);
+ ageable = entity;
+ } else
+ ageable = null;
+ }
+
+ @Override
+ public void run() {
+ if (!locked && isAgeable()) {
+ age = ageable.getAge();
+ }
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ if (isAgeable()) {
+ ageable.setAge(age);
+ }
+ }
+
+ @Override
+ public boolean toggle() {
+ locked = !locked;
+ if (isAgeable())
+ ageable.setAgeLock(locked);
+ return locked;
+ }
+
+ @Override
+ public String toString() {
+ return "Age{age=" + age + ",locked=" + locked + "}";
+ }
+}
\ No newline at end of file
diff --git a/main/java/net/citizensnpcs/trait/Anchors.java b/main/java/net/citizensnpcs/trait/Anchors.java
new file mode 100644
index 000000000..bb395170d
--- /dev/null
+++ b/main/java/net/citizensnpcs/trait/Anchors.java
@@ -0,0 +1,87 @@
+package net.citizensnpcs.trait;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.world.WorldLoadEvent;
+
+import net.citizensnpcs.api.exception.NPCLoadException;
+import net.citizensnpcs.api.trait.Trait;
+import net.citizensnpcs.api.trait.TraitName;
+import net.citizensnpcs.api.util.DataKey;
+import net.citizensnpcs.api.util.Messaging;
+import net.citizensnpcs.util.Anchor;
+import net.citizensnpcs.util.Messages;
+
+@TraitName("anchors")
+public class Anchors extends Trait {
+ private final List anchors = new ArrayList();
+
+ public Anchors() {
+ super("anchors");
+ }
+
+ public boolean addAnchor(String name, Location location) {
+ Anchor newAnchor = new Anchor(name, location);
+ if (anchors.contains(newAnchor))
+ return false;
+ anchors.add(newAnchor);
+ return true;
+ }
+
+ @EventHandler
+ public void checkWorld(WorldLoadEvent event) {
+ for (Anchor anchor : anchors)
+ if (!anchor.isLoaded())
+ anchor.load();
+ }
+
+ public Anchor getAnchor(String name) {
+ for (Anchor anchor : anchors)
+ if (anchor.getName().equalsIgnoreCase(name))
+ return anchor;
+ return null;
+ }
+
+ public List