/** * Client-side hologram implementation * * @author Illusion */ @Getter public class ClientsideHologram extends ClientsideEntity { // Main instance, used to register hitbox private final TutorialPlugin main; private final String defaultName; // Default name, a hologram can assume multiple names, depending on the player private final Location location; // Constant location, not necessarily the same as the displayed hitbox location private final boolean baby; // Is small private final List necessaryPackets = new ArrayList<>(); // Necessary packets to spawn and display a client-side hologram private final Set viewers = new HashSet<>(); // Viewer collection, used to display hitbox when created. // Cached metadata packet names, re-uses packets when displaying equal names to different players. // Expires after 5 minutes to prevent memory leaks. private final Cache cachedNames = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(); // Hitbox properties, contains a reference of the internal hitbox, displayed locations and origin hologram. // Do not re-assign this value past initialization. The value should be treated as final. @Setter private HitboxProperties hitboxProperties; private List chained = new ArrayList<>(); // Chained holograms, used when writing multiple lines of text. private UUID trackingEntity; private double trackingRange; public ClientsideHologram(TutorialPlugin main, Location location, String name) { this(main, location, name, true, false); } public ClientsideHologram(TutorialPlugin main, Location location, String name, boolean baby) { this(main, location, name, baby, false); } public ClientsideHologram(TutorialPlugin main, Location location, String name, boolean baby, boolean hitbox) { this.main = main; this.location = location; this.baby = baby; this.defaultName = name; setup(); if (hitbox) main.getClientsideEntityRegister().registerEntity(this); hitboxProperties = hitbox ? null : new HitboxProperties(null, this, null); chained.add(this); } /** * Displays multiple lines of text at a specific location. * * @param location - The location to display at * @param text - The text to display * @return Ordered collection of client-side holograms. Treat as immutable. * Collection is immutable if the line count is below 2, but adding or * removing values has no effect. */ public static List writeText(TutorialPlugin main, Location location, String... text) { if (text.length == 0) return Collections.emptyList(); ClientsideHologram top = new ClientsideHologram(main, location, text[0]); if (text.length == 1) return Collections.singletonList(top); for (int index = 1; index < text.length; index++) top.addLineBelow(text[index]); return top.chained; } private void chain(ClientsideHologram hologram, int position) { hologram.chained = chained; if (position < 0) { List newChained = new ArrayList<>(); newChained.add(hologram); newChained.addAll(chained); chained = newChained; return; } chained.set(position, hologram); } public void addLineAbove(String text) { ClientsideHologram hologram = new ClientsideHologram(main, location.add(0, 0.3, 0), text); hologram.chain(hologram, chained.indexOf(this)); } public void addLineBelow(String text) { ClientsideHologram hologram = new ClientsideHologram(main, location.add(0, -0.3, 0), text); hologram.chain(hologram, chained.size()); } private void setup() { necessaryPackets.add(createSpawnPacket()); } /** * Creates metadata packet, invisible by default * * @param name - The hologram text, non present if "" * @return ENTITY_METADATA packet */ private PacketContainer createMetadataPacket(String name) { WrapperPlayServerEntityMetadata wrapper = new WrapperPlayServerEntityMetadata(); // Wrapped packet, for easy use wrapper.setEntityID(getEntityId()); // Assign internal entity ID byte mask = 0x00; // Bitmask, armor-stand specific, NOT USED ON THE INDEX OF 1 mask = attach(mask, (byte) 0x01, baby); // 0x01 = baby // UNUSED: // 0x04 = Has arms // 0x08 = Has no baseplate // 0x10 = Is marker // Creates a metadata packet with NMS entity for data watcher ease of use. Pass NULL if NMS is giving issues EasyMetadataPacket metadata = new EasyMetadataPacket(createInstance("EntityArmorStand")); metadata.write(0, (byte) (0x20)); // Invisible metadata.write(1, 0); // Air ticks // Name if (name.isEmpty()) metadata.writeEmptyData(2, WrappedChatComponent.fromText("")); else metadata.writeOptional(2, WrappedChatComponent.fromText(name)); metadata.write(3, !name.isEmpty()); // Name is visible metadata.write(4, Boolean.TRUE); // Is silent metadata.write(5, Boolean.TRUE); // No gravity metadata.write(14, mask); // Armor stand properties wrapper.setMetadata(metadata.export()); // Exports and writes metadata into packet return wrapper.getHandle(); } /** * Adds a bit to a bitmask if a boolean is present * * @param defaultVal - The bitmask * @param toAdd - The bit * @param supposedToAdd - The boolean * @return updated bitmask */ private byte attach(byte defaultVal, byte toAdd, boolean supposedToAdd) { return supposedToAdd ? (byte) (defaultVal | toAdd) : defaultVal; } /** * Creates entity spawn packet * * @return SPAWN_ENTITY_LIVING packet, change to SPAWN_ENTITY if having issues */ private PacketContainer createSpawnPacket() { ProtocolManager manager = ProtocolLibrary.getProtocolManager(); PacketContainer spawn = manager.createPacket(PacketType.Play.Server.SPAWN_ENTITY_LIVING); spawn.getIntegers().writeSafely(0, getEntityId()); // Entity ID spawn.getUUIDs().writeSafely(0, UUID.randomUUID()); // Entity UUID spawn.getIntegers().writeSafely(1, 1); // Entity type ID spawn.getDoubles().writeSafely(0, location.getX()); // Location X spawn.getDoubles().writeSafely(1, location.getY()); // Location Y spawn.getDoubles().writeSafely(2, location.getZ()); // Location Z spawn.getBytes().writeSafely(0, (byte) (location.getYaw() / 256 * 360)); // Yaw, not used spawn.getBytes().writeSafely(1, (byte) (location.getPitch() / 256 * 360)); // Pitch, not used spawn.getBytes().writeSafely(2, (byte) (location.getPitch() / 256 * 360)); // Body rotation spawn.getShorts().writeSafely(0, (short) 0); // Velocity X spawn.getShorts().writeSafely(1, (short) 0); // Velocity Y spawn.getShorts().writeSafely(2, (short) 0); // Velocity Z return spawn; } /** * Displays the hologram with its default name. * If hitbox is present, displays hitbox. * * @param player - The player to display to */ public ClientsideHologram show(Player player) { return show(player, defaultName); } public ClientsideHologram show(Player player, String name) { if (trackingEntity != null) { ClientsideHologram newHologram = (ClientsideHologram) clone(); newHologram.trackingEntity = player.getUniqueId(); newHologram.show(player, name); return newHologram; } if (!viewers.contains(player.getUniqueId())) { // Sends spawn packets, if needed for (PacketContainer packet : necessaryPackets) { sendPacket(packet, player); } viewers.add(player.getUniqueId()); } sendPacket(getPacket(name), player); // Sends cached metadata packet if (hitboxProperties.getHitbox() != null && !isHitbox()) // Shows hitbox if present hitboxProperties.getHitbox().show(player, ""); return this; } /** * Obtains a cached ENTITY_METADATA packet * caches if not present. * * @param name - The display name * @return ENTITY_METADATA packet */ private PacketContainer getPacket(String name) { if (cachedNames.asMap().containsKey(name)) // Checks if cache contains packet return cachedNames.getIfPresent(name); // Returns if packet is present in cache PacketContainer packet = createMetadataPacket(ChatColor.translateAlternateColorCodes('&', name)); // Otherwise, creates a packet cachedNames.put(name, packet); // And caches the created packet return packet; } /** * Method called when a player clicks on the hologram. * Ignored if not a hitbox. * * @param data - The click data / event */ @Override public void registerClick(ClickData data) { if (!isHitbox()) return; super.registerClick(data); } /** * Registers a click listener, spawns and routes to hitbox * * @param consumer - The action to be run when clicked */ @Override public void onClick(Consumer consumer) { if (hitboxProperties.getHitbox() == null) // Spawns hitbox if doesn't exist spawnHitbox(); if (isHitbox()) // Acts if hitbox, routes otherwise super.onClick(consumer); else hitboxProperties.getHitbox().onClick(consumer); } /** * Pops out the hitbox to the player slightly * Used for click priority when multiple entities are stacked. * Usual offset is 0.3, max safe offset is ~0.5 * * @param player - The player to pop out to */ public void popOutHitbox(Player player) { if (!isHitbox()) { // Ensures method is run on hitbox spawnHitbox(); hitboxProperties.getHitbox().popOutHitbox(player); return; } Location hitboxLocation = getHitboxLocation(player); // Obtains hitbox location, specific to player Location playerLoc = player.getEyeLocation(); // Obtains player eye location double distance = Math.min(0.5, distance2D(playerLoc, hitboxLocation)); // Calculates ideal distance double targetAngle = Math.toDegrees(Math.atan2(playerLoc.getX() - location.getX(), playerLoc.getZ() - location.getZ())); // Calculates ideal angle double targetScaledX = Math.sin(Math.toRadians(targetAngle)) * distance; // Calculates ideal X, in 2D space (assume camera looking down) double targetScaledZ = Math.cos(Math.toRadians(targetAngle)) * distance; // Calculates ideal Z, in 2D space Location targetLoc = location.clone().add(targetScaledX, 0, targetScaledZ); // Adds the ideal X and Z, to the original location Location newLoc = targetLoc.clone(); // Clones target location for absolutely no reason. The variable had other values when implementing this algorithm sendPacket(createPositionPacket(hitboxLocation, newLoc), player); // Creates relative position packet hitboxProperties.getHitboxLocation().put(player.getUniqueId(), newLoc); // Updates internal location } /** * Returns popped out hitbox to original location- * * @param player - The player who's hitbox' location should return */ public void returnHitbox(Player player) { if (!isHitbox()) { if (hitboxProperties.getHitbox() == null) return; hitboxProperties.getHitbox().returnHitbox(player); return; } Location hitboxPos = getHitboxLocation(player); sendPacket(createPositionPacket(hitboxPos, location), player); hitboxProperties.getHitboxLocation().put(player.getUniqueId(), location); } /** * Creates relative position packet. * * @param oldPos - The old position * @param newPos - The new position * @return REL_ENTITY_MOVE packet */ private PacketContainer createPositionPacket(Location oldPos, Location newPos) { PacketContainer packet = ProtocolLibrary.getProtocolManager().createPacket(PacketType.Play.Server.REL_ENTITY_MOVE); packet.getIntegers().write(0, getEntityId()); packet.getShorts() .write(0, (short) ((newPos.getX() * 32 - oldPos.getX() * 32) * 128)) // Copied straight from wiki.vg/Protocol .write(1, (short) ((newPos.getY() * 32 - oldPos.getY() * 32) * 128)) .write(2, (short) ((newPos.getZ() * 32 - oldPos.getZ() * 32) * 128)); packet.getBooleans().write(0, false); // is on ground return packet; } /** * Obtains internal hitbox location for specific player. Spawns hitbox if doesn't exist * * @param player - The player to fetch from * @return hitbox location */ private Location getHitboxLocation(Player player) { if (hitboxProperties.getHitboxLocation() == null) spawnHitbox(); return hitboxProperties.getHitboxLocation().getOrDefault(player.getUniqueId(), location); } /** * Spawns a hitbox, ignored if hitbox already exists */ private void spawnHitbox() { if (hitboxProperties.getHitbox() != null) return; ClientsideHologram hitbox = new ClientsideHologram(main, location.clone().add(0, getHeight(), 0), "", baby, true); for (UUID viewer : viewers) { Player player = Bukkit.getPlayer(viewer); if (player == null) continue; hitbox.show(player); } hitboxProperties.setHitboxLocation(new HashMap<>()); hitboxProperties.setHitbox(hitbox); hitbox.hitboxProperties = hitboxProperties; } @Override public void stopTracking(Player player) { UUID uuid = player.getUniqueId(); viewers.remove(player.getUniqueId()); hitboxProperties.getHitboxLocation().remove(uuid); } /** * Calculates line length between 2 points in a 2D space * Given 2 3D points, assumes X and Z from both points * * @param one - 3D point 1 * @param two - 3D point 2 * @return line length between both points */ private double distance2D(Location one, Location two) { return Math.hypot(one.getX() - two.getX(), one.getZ() - two.getZ()); } public boolean isHitbox() { return hitboxProperties.getHitbox() == this; } private float getHeight() { return baby ? 0.9875f : 1.975f; } public ClientsideHologram trackPlayer(Player player, double radius) { if (trackingEntity == null) { trackingEntity = player.getUniqueId(); trackingRange = radius; return this; } ClientsideHologram newHologram = (ClientsideHologram) clone(); newHologram.trackingEntity = player.getUniqueId(); newHologram.trackingRange = radius; newHologram.show(player, defaultName); return newHologram; } public void updateTracker(Player player) { if (trackingEntity == null || !trackingEntity.equals(player.getUniqueId())) return; Vector direction = player.getEyeLocation().getDirection().clone().normalize().multiply(trackingRange); Location targetLoc = player.getEyeLocation().add(direction); for (ClientsideHologram hologram : chained) { Location originalLoc = hologram.getLocation(); double yDiff = originalLoc.getY() - location.getY(); Location newTargetLoc = targetLoc.clone().add(0, yDiff, 0); sendPacket(createPositionPacket(originalLoc, newTargetLoc), player); } } private ClientsideHologram cloneWithoutChain() { ClientsideHologram hologram = new ClientsideHologram(main, location, defaultName, baby, isHitbox()); hologram.viewers.addAll(viewers); hologram.trackingEntity = trackingEntity; hologram.trackingRange = trackingRange; return hologram; } @Override protected Object clone() { List chain = new ArrayList<>(); ClientsideHologram thisInstance = null; for (ClientsideHologram chained : this.chained) { ClientsideHologram newInstance = chained.cloneWithoutChain(); if (chained == this) thisInstance = newInstance; chain.add(newInstance); } for (ClientsideHologram holo : chain) { holo.chained = chain; } return thisInstance; } }