Rework player teleportation.

I reworked the classes and some teleportation operations. Now teleportation should find the closest available spot, instead of always being the highest block at original Y location. Part of #1994.
Also, I fixed an issue that portals stopped working if some conflicting options were enabled. Now portals will not work only if nether is disabled in config.
This commit is contained in:
BONNe 2022-09-30 01:26:31 +03:00
parent 173808b787
commit 755aeb866e
5 changed files with 1674 additions and 4 deletions

View File

@ -19,7 +19,6 @@ import world.bentobox.bentobox.api.user.Notifier;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.commands.BentoBoxCommand;
import world.bentobox.bentobox.database.DatabaseSetup;
import world.bentobox.bentobox.hooks.DynmapHook;
import world.bentobox.bentobox.hooks.MultiverseCoreHook;
import world.bentobox.bentobox.hooks.VaultHook;
import world.bentobox.bentobox.hooks.placeholders.PlaceholderAPIHook;
@ -28,7 +27,7 @@ import world.bentobox.bentobox.listeners.BlockEndDragon;
import world.bentobox.bentobox.listeners.DeathListener;
import world.bentobox.bentobox.listeners.JoinLeaveListener;
import world.bentobox.bentobox.listeners.PanelListenerManager;
import world.bentobox.bentobox.listeners.PortalTeleportationListener;
import world.bentobox.bentobox.listeners.teleports.PlayerTeleportListener;
import world.bentobox.bentobox.listeners.StandardSpawnProtectionListener;
import world.bentobox.bentobox.managers.AddonsManager;
import world.bentobox.bentobox.managers.BlueprintsManager;
@ -288,8 +287,8 @@ public class BentoBox extends JavaPlugin {
manager.registerEvents(new PanelListenerManager(), this);
// Standard Nether/End spawns protection
manager.registerEvents(new StandardSpawnProtectionListener(this), this);
// Nether portals
manager.registerEvents(new PortalTeleportationListener(this), this);
// Player portals
manager.registerEvents(new PlayerTeleportListener(this), this);
// End dragon blocking
manager.registerEvents(new BlockEndDragon(this), this);
// Banned visitor commands

View File

@ -314,6 +314,10 @@ public class Settings implements ConfigObject {
@ConfigEntry(path = "island.safe-spot-search-vertical-range", since = "1.19.1")
private int safeSpotSearchVerticalRange = 400;
@ConfigComment("By default, If the destination is not safe, the plugin will try to search for a safe spot around the destination,")
@ConfigEntry(path = "island.safe-spot-search-range", since = "1.21.0")
private int safeSpotSearchRange = 16;
/* WEB */
@ConfigComment("Toggle whether BentoBox can connect to GitHub to get data about updates and addons.")
@ConfigComment("Disabling this will result in the deactivation of the update checker and of some other")
@ -907,19 +911,65 @@ public class Settings implements ConfigObject {
this.minPortalSearchRadius = minPortalSearchRadius;
}
/**
* Gets safe spot search vertical range.
*
* @return the safe spot search vertical range
*/
public int getSafeSpotSearchVerticalRange() {
return safeSpotSearchVerticalRange;
}
/**
* Sets safe spot search vertical range.
*
* @param safeSpotSearchVerticalRange the safe spot search vertical range
*/
public void setSafeSpotSearchVerticalRange(int safeSpotSearchVerticalRange) {
this.safeSpotSearchVerticalRange = safeSpotSearchVerticalRange;
}
/**
* Is slow deletion boolean.
*
* @return the boolean
*/
public boolean isSlowDeletion() {
return slowDeletion;
}
/**
* Sets slow deletion.
*
* @param slowDeletion the slow deletion
*/
public void setSlowDeletion(boolean slowDeletion) {
this.slowDeletion = slowDeletion;
}
/**
* Gets safe spot search range.
*
* @return the safe spot search range
*/
public int getSafeSpotSearchRange()
{
return safeSpotSearchRange;
}
/**
* Sets safe spot search range.
*
* @param safeSpotSearchRange the safe spot search range
*/
public void setSafeSpotSearchRange(int safeSpotSearchRange)
{
this.safeSpotSearchRange = safeSpotSearchRange;
}
}

View File

@ -0,0 +1,296 @@
//
// Created by BONNe
// Copyright - 2022
//
package world.bentobox.bentobox.listeners.teleports;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.event.player.PlayerPortalEvent;
import org.eclipse.jdt.annotation.NonNull;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.database.objects.Island;
/**
* This abstract class contains all common methods for entity and player teleportation.
*/
public abstract class AbstractTeleportListener
{
/**
* Instance of Teleportation processor.
* @param bentoBox BentoBox plugin.
*/
AbstractTeleportListener(@NonNull BentoBox bentoBox)
{
this.plugin = bentoBox;
this.inPortal = new HashSet<>();
this.inTeleport = new HashSet<>();
}
// ---------------------------------------------------------------------
// Section: Methods
// ---------------------------------------------------------------------
/**
* Get island at the given location
* @return optional island at given location
*/
protected Optional<Island> getIsland(Location location)
{
return this.plugin.getIslands().getProtectedIslandAt(location);
}
/**
* Check if vanilla portals should be used
*
* @param world - game mode world
* @param environment - environment
* @return true or false
*/
protected boolean isMakePortals(World world, World.Environment environment)
{
return this.plugin.getIWM().getAddon(world).
map(gameMode -> this.isMakePortals(gameMode, environment)).
orElse(false);
}
/**
* Check if vanilla portals should be used
*
* @param gameMode - game mode
* @param environment - environment
* @return true or false
*/
protected boolean isMakePortals(GameModeAddon gameMode, World.Environment environment)
{
return environment.equals(World.Environment.NETHER) ?
gameMode.getWorldSettings().isMakeNetherPortals() :
gameMode.getWorldSettings().isMakeEndPortals();
}
/**
* Check if nether or end are generated
*
* @param overWorld - game world
* @param env - environment
* @return true or false
*/
protected boolean isAllowedInConfig(World overWorld, World.Environment env)
{
return env.equals(World.Environment.NETHER) ?
this.plugin.getIWM().isNetherGenerate(overWorld) :
this.plugin.getIWM().isEndGenerate(overWorld);
}
/**
* Check if the default nether or end are allowed by the server settings
*
* @param environment - environment
* @return true or false
*/
protected boolean isAllowedOnServer(World.Environment environment)
{
return environment.equals(World.Environment.NETHER) ? Bukkit.getAllowNether() : Bukkit.getAllowEnd();
}
/**
* Check if nether or end islands are generated
*
* @param overWorld - over world
* @param environment - environment
* @return true or false
*/
protected boolean isIslandWorld(World overWorld, World.Environment environment)
{
return environment.equals(World.Environment.NETHER) ?
this.plugin.getIWM().isNetherIslands(overWorld) :
this.plugin.getIWM().isEndIslands(overWorld);
}
/**
* Get the nether or end world
*
* @param overWorld - over world
* @param environment - environment
* @return nether or end world
*/
protected World getNetherEndWorld(World overWorld, World.Environment environment)
{
return environment.equals(World.Environment.NETHER) ?
this.plugin.getIWM().getNetherWorld(overWorld) :
this.plugin.getIWM().getEndWorld(overWorld);
}
/**
* Check if the island has a nether or end island already
*
* @param island - island
* @param environment - environment
* @return true or false
*/
protected boolean hasPartnerIsland(Island island, World.Environment environment)
{
return environment.equals(World.Environment.NETHER) ? island.hasNetherIsland() : island.hasEndIsland();
}
/**
* This method calculates the maximal search area for portal.
* @param location Location from which search should happen.
* @param island Island that contains the search point.
* @return Search range for portal.
*/
protected int calculateSearchRadius(Location location, Island island)
{
int diff;
if (island.onIsland(location))
{
// Find max x or max z
int x = Math.abs(island.getProtectionCenter().getBlockX() - location.getBlockX());
int z = Math.abs(island.getProtectionCenter().getBlockZ() - location.getBlockZ());
diff = Math.min(this.plugin.getSettings().getSafeSpotSearchRange(),
island.getProtectionRange() - Math.max(x, z));
}
else
{
diff = this.plugin.getSettings().getSafeSpotSearchRange();
}
return diff;
}
/**
* This method calculates location for portal.
* @param fromLocation Location from which teleportation happens.
* @param fromWorld World from which teleportation happens.
* @param toWorld The target world.
* @param environment Portal variant.
* @param canCreatePortal Indicates if portal should be created or not.
* @return Location for new portal.
*/
protected Location calculateLocation(Location fromLocation,
World fromWorld,
World toWorld,
World.Environment environment,
boolean canCreatePortal)
{
// Null check - not that useful
if (fromWorld == null || toWorld == null)
{
return null;
}
Location toLocation = fromLocation.toVector().toLocation(toWorld);
if (!this.isMakePortals(fromWorld, environment))
{
toLocation = this.getIsland(fromLocation).
map(island -> island.getSpawnPoint(toWorld.getEnvironment())).
orElse(toLocation);
}
// Limit Y to the min/max world height.
toLocation.setY(Math.max(Math.min(toLocation.getY(), toWorld.getMaxHeight()), toWorld.getMinHeight()));
if (!canCreatePortal)
{
// Legacy portaling
return toLocation;
}
// Make portals
// For anywhere other than the end - it is the player's location that is used
if (!environment.equals(World.Environment.THE_END))
{
return toLocation;
}
// If the-end then we want the platform to always be generated in the same place no matter where
// they enter the portal
final int x = fromLocation.getBlockX();
final int z = fromLocation.getBlockZ();
final int y = fromLocation.getBlockY();
int i = x;
int j = z;
int k = y;
// If the from is not a portal, then we have to find it
if (!fromLocation.getBlock().getType().equals(Material.END_PORTAL))
{
// Find the portal - due to speed, it is possible that the player will be below or above the portal
for (k = toWorld.getMinHeight(); (k < fromWorld.getMaxHeight()) &&
!fromWorld.getBlockAt(x, k, z).getType().equals(Material.END_PORTAL); k++);
}
// Find the maximum x and z corner
for (; (i < x + 5) && fromWorld.getBlockAt(i, k, z).getType().equals(Material.END_PORTAL); i++) ;
for (; (j < z + 5) && fromWorld.getBlockAt(x, k, j).getType().equals(Material.END_PORTAL); j++) ;
// Mojang end platform generation is:
// AIR
// AIR
// OBSIDIAN
// and player is placed on second air block above obsidian.
// If Y coordinate is below 2, then obsidian platform is not generated and player falls in void.
return new Location(toWorld, i, Math.max(toWorld.getMinHeight() + 2, k), j);
}
/**
* This method returns if missing islands should be generated uppon teleportation.
* Can happen only in non-custom generators.
* @param overWorld OverWorld
* @return {@code true} if missing islands must be pasted, {@code false} otherwise.
*/
protected boolean isPastingMissingIslands(World overWorld)
{
return this.plugin.getIWM().isPasteMissingIslands(overWorld) &&
!this.plugin.getIWM().isUseOwnGenerator(overWorld);
}
// ---------------------------------------------------------------------
// Section: Variables
// ---------------------------------------------------------------------
/**
* BentoBox plugin instance.
*/
@NonNull
protected final BentoBox plugin;
/**
* Set of entities that currently is inside portal.
*/
protected final Set<UUID> inPortal;
/**
* Set of entities that currently is in teleportation.
*/
protected final Set<UUID> inTeleport;
}

View File

@ -0,0 +1,438 @@
//
// Created by BONNe
// Copyright - 2022
//
package world.bentobox.bentobox.listeners.teleports;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPortalEnterEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.util.Vector;
import org.eclipse.jdt.annotation.NonNull;
import java.util.Objects;
import java.util.UUID;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.blueprints.Blueprint;
import world.bentobox.bentobox.blueprints.BlueprintPaster;
import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBundle;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.util.Util;
import world.bentobox.bentobox.util.teleport.ClosestSafeSpotTeleport;
public class PlayerTeleportListener extends AbstractTeleportListener implements Listener
{
/**
* Instantiates a new Portal teleportation listener.
*
* @param plugin the plugin
*/
public PlayerTeleportListener(@NonNull BentoBox plugin)
{
super(plugin);
}
// ---------------------------------------------------------------------
// Section: Listeners
// ---------------------------------------------------------------------
/**
* This listener checks player portal events and triggers appropriate methods to transfer
* players to the correct location in other dimension.
*
* This event is triggered when player is about to being teleported because of contact with the
* nether portal or end gateway portal (exit portal triggers respawn).
*
* This event is not called if nether/end is disabled in server settings.
*
* @param event the player portal event.
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlayerPortalEvent(PlayerPortalEvent event)
{
switch (event.getCause())
{
case NETHER_PORTAL -> this.portalProcess(event, World.Environment.NETHER);
case END_PORTAL, END_GATEWAY -> this.portalProcess(event, World.Environment.THE_END);
}
}
/**
* Fires the event if nether or end is disabled at the system level
*
* @param event - EntityPortalEnterEvent
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlayerPortal(EntityPortalEnterEvent event)
{
if (!EntityType.PLAYER.equals(event.getEntity().getType()))
{
// This handles only players.
return;
}
Entity entity = event.getEntity();
Material type = event.getLocation().getBlock().getType();
UUID uuid = entity.getUniqueId();
if (this.inPortal.contains(uuid) ||
!this.plugin.getIWM().inWorld(Util.getWorld(event.getLocation().getWorld())))
{
return;
}
this.inPortal.add(uuid);
if (!Bukkit.getAllowNether() && type.equals(Material.NETHER_PORTAL))
{
// Schedule a time
Bukkit.getScheduler().runTaskLater(this.plugin, () ->
{
// Check again if still in portal
if (this.inPortal.contains(uuid))
{
// Create new PlayerPortalEvent
PlayerPortalEvent en = new PlayerPortalEvent((Player) entity,
event.getLocation(),
null,
PlayerTeleportEvent.TeleportCause.NETHER_PORTAL,
0,
false,
0);
this.onPlayerPortalEvent(en);
}
}, 40);
return;
}
// End portals are instant transfer
if (!Bukkit.getAllowEnd() && (type.equals(Material.END_PORTAL) || type.equals(Material.END_GATEWAY)))
{
// Create new PlayerPortalEvent
PlayerPortalEvent en = new PlayerPortalEvent((Player) entity,
event.getLocation(),
null,
type.equals(Material.END_PORTAL) ? PlayerTeleportEvent.TeleportCause.END_PORTAL : PlayerTeleportEvent.TeleportCause.END_GATEWAY,
0,
false,
0);
this.onPlayerPortalEvent(en);
}
}
/**
* Remove inPortal flag only when player exits the portal
*
* @param event player move event
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onExitPortal(PlayerMoveEvent event)
{
if (!this.inPortal.contains(event.getPlayer().getUniqueId()))
{
return;
}
if (event.getTo() != null && !event.getTo().getBlock().getType().equals(Material.NETHER_PORTAL))
{
// Player exits nether portal.
this.inPortal.remove(event.getPlayer().getUniqueId());
this.inTeleport.remove(event.getPlayer().getUniqueId());
}
}
// ---------------------------------------------------------------------
// Section: Processors
// ---------------------------------------------------------------------
private void portalProcess(PlayerPortalEvent event, World.Environment environment)
{
World fromWorld = event.getFrom().getWorld();
World overWorld = Util.getWorld(fromWorld);
if (overWorld == null || !this.plugin.getIWM().inWorld(overWorld))
{
// Not teleporting from/to bentobox worlds.
return;
}
if (!this.isAllowedInConfig(overWorld, environment))
{
// World is disabled in config. Do not teleport player.
event.setCancelled(true);
return;
}
if (!this.isAllowedOnServer(environment))
{
// World is disabled in bukkit. Event is not triggered, but cancel by chance.
event.setCancelled(true);
}
if (this.inTeleport.contains(event.getPlayer().getUniqueId()))
{
// Player is already in teleportation.
return;
}
this.inTeleport.add(event.getPlayer().getUniqueId());
if (fromWorld.equals(overWorld) && !this.isIslandWorld(overWorld, environment))
{
// This is not island world. Use standard nether or end world teleportation.
this.handleToStandardNetherOrEnd(event, overWorld, environment);
return;
}
if (!fromWorld.equals(overWorld) && !this.isIslandWorld(overWorld, environment))
{
// If entering a portal in the other world, teleport to a portal in overworld if
// there is one
this.handleFromStandardNetherOrEnd(event, overWorld, environment);
return;
}
// To the nether/end or overworld.
World toWorld = !fromWorld.getEnvironment().equals(environment) ?
this.getNetherEndWorld(overWorld, environment) : overWorld;
// Set whether portals should be created or not
event.setCanCreatePortal(this.isMakePortals(overWorld, environment));
// Default 16 is will always end up placing portal as close to X/8 coordinate as possible.
// In most situations, 2 block value should be enough... I hope.
event.setCreationRadius(2);
// Set the destination location
// If portals cannot be created, then destination is the spawn point, otherwise it's the vector
event.setTo(this.calculateLocation(event.getFrom(), fromWorld, toWorld, environment, event.getCanCreatePortal()));
// Find the distance from edge of island's protection and set the search radius
this.getIsland(event.getTo()).ifPresent(island ->
event.setSearchRadius(this.calculateSearchRadius(event.getTo(), island)));
// Check if there is an island there or not
if (this.isPastingMissingIslands(overWorld) &&
this.isAllowedInConfig(overWorld, environment) &&
this.isIslandWorld(overWorld, environment) &&
this.getNetherEndWorld(overWorld, environment) != null &&
this.getIsland(event.getTo()).
filter(island -> this.hasPartnerIsland(island, environment)).
map(island -> {
event.setCancelled(true);
this.pasteNewIsland(event.getPlayer(), event.getTo(), island, environment);
return true;
}).
orElse(false))
{
// If there is no island, then processor already created island. Nothing to do more.
return;
}
if (!event.isCancelled() && event.getCanCreatePortal())
{
// Let the server teleport
return;
}
if (environment.equals(World.Environment.THE_END))
{
// Prevent death from hitting the ground while calculating location.
event.getPlayer().setVelocity(new Vector(0,0,0));
event.getPlayer().setFallDistance(0);
}
// If we do not generate portals, teleportation should happen manually with safe spot builder.
// Otherwise, we could end up with situations when player is placed in mid air, if teleportation
// is done instantly.
// Our safe spot task is triggered in next tick, however, end teleportation happens in the same tick.
// It is placed outside THE_END check, as technically it could happen with the nether portal too.
// If there is a portal to go to already, then the player will go there
Bukkit.getScheduler().runTask(this.plugin, () -> {
if (!event.getPlayer().getWorld().equals(toWorld))
{
// Else manually teleport entity
ClosestSafeSpotTeleport.builder(this.plugin).
entity(event.getPlayer()).
location(event.getTo()).
portal().
successRunnable(() -> {
// Reset velocity just in case.
event.getPlayer().setVelocity(new Vector(0,0,0));
event.getPlayer().setFallDistance(0);
}).
build();
}
});
}
/**
* Handle teleport from or to standard nether or end
* @param event - PlayerPortalEvent
* @param overWorld - over world
* @param environment - environment involved
*/
private void handleToStandardNetherOrEnd(PlayerPortalEvent event,
World overWorld,
World.Environment environment)
{
World toWorld = Objects.requireNonNull(this.getNetherEndWorld(overWorld, environment));
Location spawnPoint = toWorld.getSpawnLocation();
// If going to the nether and nether portals are active then just teleport to approx location
if (environment.equals(World.Environment.NETHER) &&
this.plugin.getIWM().getWorldSettings(overWorld).isMakeNetherPortals())
{
spawnPoint = event.getFrom().toVector().toLocation(toWorld);
}
// If spawn is set as 0,63,0 in the End then move it to 100, 50 ,0.
if (environment.equals(World.Environment.THE_END) && spawnPoint.getBlockX() == 0 && spawnPoint.getBlockZ() == 0)
{
// Set to the default end spawn
spawnPoint = new Location(toWorld, 100, 50, 0);
toWorld.setSpawnLocation(100, 50, 0);
}
if (this.isAllowedOnServer(environment))
{
// To Standard Nether or end
event.setTo(spawnPoint);
}
else
{
// Teleport to standard nether or end
ClosestSafeSpotTeleport.builder(this.plugin).
entity(event.getPlayer()).
location(spawnPoint).
portal().
build();
}
}
/**
* Handle teleport from or to standard nether or end (end is not possible because EXIT PORTAL triggers RESPAWN event)
* @param event - PlayerPortalEvent
* @param overWorld - over world
* @param environment - environment involved
*/
private void handleFromStandardNetherOrEnd(PlayerPortalEvent event, World overWorld, World.Environment environment)
{
if (environment.equals(World.Environment.NETHER) &&
this.plugin.getIWM().getWorldSettings(overWorld).isMakeNetherPortals())
{
// Set to location directly to the from location.
event.setTo(event.getFrom().toVector().toLocation(overWorld));
// Update portal search radius.
this.getIsland(event.getTo()).ifPresent(island ->
event.setSearchRadius(this.calculateSearchRadius(event.getTo(), island)));
event.setCanCreatePortal(true);
// event.setCreationRadius(16); 16 is default creation radius.
}
else
{
// Cannot be portal. Should recalculate position.
Location toLocation;
Island island = this.plugin.getIslandsManager().getIsland(overWorld, event.getPlayer().getUniqueId());
if (island == null)
{
// What to do? Player do not have an island! Check for spawn?
// TODO: SPAWN CHECK.
toLocation = event.getFrom();
}
else
{
// TODO: Island Respawn, Bed, Default home location check.
toLocation = island.getSpawnPoint(World.Environment.NORMAL);
}
event.setTo(toLocation);
}
if (!this.isAllowedOnServer(environment))
{
// Custom portal handling.
event.setCancelled(true);
// Teleport to standard nether or end
ClosestSafeSpotTeleport.builder(this.plugin).
entity(event.getPlayer()).
location(event.getTo()).
portal().
build();
}
}
/**
* Pastes the default nether or end island and teleports the player to the island's spawn point
* @param player - player to teleport after pasting
* @param to - the fallback location if a spawn point is not part of the blueprint
* @param island - the island
* @param environment - NETHER or THE_END
*/
private void pasteNewIsland(Player player,
Location to,
Island island,
World.Environment environment)
{
// Paste then teleport player
this.plugin.getIWM().getAddon(island.getWorld()).ifPresent(addon ->
{
// Get the default bundle's nether or end blueprint
BlueprintBundle blueprintBundle = plugin.getBlueprintsManager().getDefaultBlueprintBundle(addon);
if (blueprintBundle != null)
{
Blueprint bluePrint = this.plugin.getBlueprintsManager().getBlueprints(addon).
get(blueprintBundle.getBlueprint(environment));
if (bluePrint != null)
{
new BlueprintPaster(this.plugin, bluePrint, to.getWorld(), island).
paste().
thenAccept(state -> ClosestSafeSpotTeleport.builder(this.plugin).
entity(player).
location(island.getSpawnPoint(environment) == null ? to : island.getSpawnPoint(environment)).
portal().
build());
}
else
{
this.plugin.logError("Could not paste default island in nether or end. " +
"Is there a nether-island or end-island blueprint?");
}
}
});
}
}

View File

@ -0,0 +1,887 @@
//
// Created by BONNe
// Copyright - 2022
//
package world.bentobox.bentobox.util.teleport;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.ChunkSnapshot;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import org.eclipse.jdt.annotation.Nullable;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.util.Pair;
import world.bentobox.bentobox.util.Util;
public class ClosestSafeSpotTeleport
{
/**
* Teleports and entity to a safe spot on island
*
* @param builder - safe spot teleport builder
*/
ClosestSafeSpotTeleport(Builder builder)
{
this.plugin = builder.getPlugin();
this.entity = builder.getEntity();
this.location = builder.getLocation();
this.portal = builder.isPortal();
this.successRunnable = builder.getSuccessRunnable();
this.failRunnable = builder.getFailRunnable();
this.failureMessage = builder.getFailureMessage();
this.result = builder.getResult();
this.world = Objects.requireNonNull(this.location.getWorld());
this.cancelIfFail = builder.isCancelIfFail();
// Try starting location
Util.getChunkAtAsync(this.location).thenRun(this::checkLocation);
}
/**
* This is main method that triggers safe spot search.
* It starts with the given location and afterwards checks all blocks in required area.
*/
private void checkLocation()
{
if (this.plugin.getIslandsManager().isSafeLocation(this.location))
{
if (!this.portal)
{
// If this is not a portal teleport, then go to the safe location immediately
this.teleportEntity(this.location);
// Position search is completed. Quit faster.
return;
}
}
// Players should not be teleported outside protection range if they already are in it.
this.boundingBox = this.plugin.getIslandsManager().getIslandAt(this.location).
map(Island::getProtectionBoundingBox).
orElseGet(() -> {
int protectionRange = this.plugin.getIWM().getIslandProtectionRange(this.world);
return new BoundingBox(this.location.getBlockX() - protectionRange,
Math.max(this.world.getMinHeight(), this.location.getBlockY() - protectionRange),
this.location.getBlockZ() - protectionRange,
this.location.getBlockX() + protectionRange,
Math.min(this.world.getMaxHeight(), this.location.getBlockY() + protectionRange),
this.location.getBlockZ() + protectionRange);
});
// The maximal range of search.
this.range = Math.min(this.plugin.getSettings().getSafeSpotSearchRange(), (int) this.boundingBox.getWidthX() / 2);
// The block queue contains all possible positions where player can be teleported. The queue will not be populated
// with all blocks, as the validation would not allow it.ss
this.blockQueue = new PriorityQueue<>(this.range * 2, ClosestSafeSpotTeleport.POSITION_COMPARATOR);
// Get chunks to scan
this.chunksToScanIterator = this.getChunksToScan().iterator();
// Start a recurring task until done or cancelled
this.task = Bukkit.getScheduler().runTaskTimer(this.plugin, this::gatherChunks, 0L, CHUNK_LOAD_SPEED);
}
/**
* This method loads all chunks in async and populates blockQueue with all blocks.
*/
private void gatherChunks()
{
// Set a flag so this is only run if it's not already in progress
if (this.checking.get())
{
return;
}
this.checking.set(true);
if (!this.portal && !this.blockQueue.isEmpty() && this.blockQueue.peek().distance() < 5)
{
// Position is found? Well most likely (not in all situations) position in block queue is already
// the best position. The only bad situations could happen if position is on chunk borders.
this.finishTask();
return;
}
if (!this.chunksToScanIterator.hasNext())
{
// Chunk scanning has completed. Now check positions.
this.finishTask();
return;
}
// Get the chunk
Pair<Integer, Integer> chunkPair = this.chunksToScanIterator.next();
this.chunksToScanIterator.remove();
// Get the chunk snapshot and scan it
Util.getChunkAtAsync(this.world, chunkPair.x, chunkPair.z).
thenApply(Chunk::getChunkSnapshot).
whenCompleteAsync((snapshot, e) ->
{
if (snapshot != null)
{
// Find best spot based on collected information chunks.
this.scanAndPopulateBlockQueue(snapshot);
}
this.checking.set(false);
});
}
/**
* Gets a set of chunk coordinates that will be scanned.
*
* @return - list of chunk coordinates to be scanned
*/
private List<Pair<Integer, Integer>> getChunksToScan()
{
List<Pair<Integer, Integer>> chunksToScan = new ArrayList<>();
int x = this.location.getBlockX();
int z = this.location.getBlockZ();
int range = 20;
// Normalize block coordinates to chunk coordinates and add extra 1 for visiting.
int numberOfChunks = (((x + range) >> 4) - ((x - range) >> 4) + 1) *
(((z + range) >> 4) - ((z - range) >> 4) + 1);
// Ideally it would be if visitor switch from clockwise to counter-clockwise if X % 16 < 8 and
// up to down if Z % 16 < 8.
int offsetX = 0;
int offsetZ = 0;
for (int i = 0; i < numberOfChunks; ++i)
{
int locationX = x + (offsetX << 4);
int locationZ = z + (offsetZ << 4);
this.addChunk(chunksToScan, new Pair<>(locationX, locationZ), new Pair<>(locationX >> 4, locationZ >> 4));
if (Math.abs(offsetX) <= Math.abs(offsetZ) && (offsetX != offsetZ || offsetX >= 0))
{
offsetX += ((offsetZ >= 0) ? 1 : -1);
}
else
{
offsetZ += ((offsetX >= 0) ? -1 : 1);
}
}
return chunksToScan;
}
/**
* This method adds chunk coordinates to the given chunksToScan list.
* The limitation is that if location is in island, then block coordinate must also be in island space.
* @param chunksToScan List of chunks that will be scanned.
* @param blockCoord Block coordinates that must be in island.
* @param chunkCoord Chunk coordinate.
*/
private void addChunk(List<Pair<Integer, Integer>> chunksToScan,
Pair<Integer, Integer> blockCoord,
Pair<Integer, Integer> chunkCoord)
{
if (!chunksToScan.contains(chunkCoord) &&
this.plugin.getIslandsManager().getIslandAt(this.location).
map(is -> is.inIslandSpace(blockCoord)).orElse(true))
{
chunksToScan.add(chunkCoord);
}
}
/**
* This method populates block queue with all blocks that player can be teleported to.
* Add only positions that are inside BoundingBox and is safe for teleportation.
* @param chunkSnapshot Spigot Chunk Snapshot with blocks.
*/
private void scanAndPopulateBlockQueue(ChunkSnapshot chunkSnapshot)
{
int startY = this.location.getBlockY();
int minY = this.world.getMinHeight();
int maxY = this.world.getMaxHeight();
Vector blockVector = new Vector(this.location.getBlockX(), this.location.getBlockY(), this.location.getBlockZ());
int chunkX = chunkSnapshot.getX() << 4;
int chunkZ = chunkSnapshot.getZ() << 4;
for (int x = 0; x < 16; x++)
{
for (int z = 0; z < 16; z++)
{
for (int y = Math.max(minY, startY - this.range); y < Math.min(maxY, startY + this.range); y++)
{
Vector positionVector = new Vector(chunkX + x, y, chunkZ + z);
if (this.boundingBox.contains(positionVector))
{
// Process positions that are inside bounding box of search area.
PositionData positionData = new PositionData(
positionVector,
chunkSnapshot.getBlockType(x, y - 1, z),
y < maxY ? chunkSnapshot.getBlockType(x, y, z) : null,
y + 1 < maxY ? chunkSnapshot.getBlockType(x, y + 1, z) : null,
blockVector.distanceSquared(positionVector));
if (this.plugin.getIslandsManager().checkIfSafe(this.world,
positionData.block,
positionData.spaceOne,
positionData.spaceTwo))
{
// Add only safe locations to the queue.
this.blockQueue.add(positionData);
}
}
}
}
}
}
/**
* This method finishes the chunk loading task and checks from all remaining positions in block queue
* to find the best location for teleportation.
*
* This method stops position finding task and process teleporation.
*/
private void finishTask()
{
// Still Async!
// Nothing left to check and still not canceled
this.task.cancel();
if (this.scanBlockQueue())
{
return;
}
if (this.portal && this.noPortalPosition != null)
{
this.teleportEntity(this.noPortalPosition);
}
else if (this.entity instanceof Player player)
{
// Return to main thread and teleport the player
Bukkit.getScheduler().runTask(this.plugin, () ->
{
// Failed, no safe spot
if (!this.failureMessage.isEmpty())
{
User.getInstance(this.entity).notify(this.failureMessage);
}
// Check highest block
Block highestBlock = this.world.getHighestBlockAt(this.location);
if (highestBlock.getType().isSolid() &&
this.plugin.getIslandsManager().isSafeLocation(highestBlock.getLocation()))
{
// Try to teleport player to the highest block.
this.asyncTeleport(highestBlock.getLocation().add(new Vector(0.5D, 0D, 0.5D)));
return;
}
else if (!this.plugin.getIWM().inWorld(this.entity.getLocation()))
{
// Last resort
player.performCommand("spawn");
}
else if (!this.cancelIfFail)
{
// Create a spot for the player to be
if (this.world.getEnvironment().equals(World.Environment.NETHER))
{
this.makeAndTeleport(Material.NETHERRACK);
}
else if (this.world.getEnvironment().equals(World.Environment.THE_END))
{
this.makeAndTeleport(Material.END_STONE);
}
else
{
this.makeAndTeleport(Material.COBBLESTONE);
}
}
if (this.failRunnable != null)
{
Bukkit.getScheduler().runTask(this.plugin, this.failRunnable);
}
this.result.complete(false);
});
}
else
{
// We do not teleport entities if position failed.
if (this.failRunnable != null)
{
Bukkit.getScheduler().runTask(this.plugin, this.failRunnable);
}
this.result.complete(false);
}
}
/**
* This method creates a spot in start location for player to be teleported to. It creates 2 base material blocks
* above location and fills the space between them with air.
* @param baseMaterial Material that will be for top and bottom block.
*/
private void makeAndTeleport(Material baseMaterial)
{
this.location.getBlock().getRelative(BlockFace.DOWN).setType(baseMaterial, false);
this.location.getBlock().setType(Material.AIR, false);
this.location.getBlock().getRelative(BlockFace.UP).setType(Material.AIR, false);
this.location.getBlock().getRelative(BlockFace.UP).getRelative(BlockFace.UP).setType(baseMaterial, false);
// Teleport player to the location of the empty space.
this.asyncTeleport(this.location.clone().add(new Vector(0.5D, 0D, 0.5D)));
}
/**
* This method scans all populated positions and returns true if position is found, or false, if not.
* @return {@code true} if safe position is found, otherwise false.
*/
private boolean scanBlockQueue()
{
boolean blockFound = false;
while (!this.blockQueue.isEmpty() && !blockFound)
{
blockFound = this.checkPosition(this.blockQueue.poll());
}
return blockFound;
}
/**
* This method triggers a task that will teleport entity in a main thread.
*/
private void teleportEntity(final Location location)
{
// Return to main thread and teleport the player
Bukkit.getScheduler().runTask(this.plugin, () -> this.asyncTeleport(location));
}
/**
* This method performs async teleportation and runs end tasks for spot-finder.
* @param location Location where player should be teleported.
*/
private void asyncTeleport(final Location location)
{
Util.teleportAsync(this.entity, location).thenRun(() ->
{
if (this.successRunnable != null)
{
Bukkit.getScheduler().runTask(this.plugin, this.successRunnable);
}
this.result.complete(true);
});
}
/**
* This method checks if given position is valid for teleportation.
* If query should find portal, then it marks first best position as noPortalPosition and continues
* to search for a valid portal.
* If query is not in portal mode, then return first valid position.
* @param positionData Position data that must be checked.
* @return {@code true} if position is found and no extra processing required, {@code false} otherwise.
*/
private boolean checkPosition(PositionData positionData)
{
if (this.portal)
{
if (Material.NETHER_PORTAL.equals(positionData.spaceOne()) ||
Material.NETHER_PORTAL.equals(positionData.spaceTwo()))
{
// Portal is found. Teleport entity to the portal location.
this.teleportEntity(new Location(this.world,
positionData.vector().getBlockX() + 0.5,
positionData.vector().getBlockY() + 0.1,
positionData.vector().getBlockZ() + 0.5,
this.location.getYaw(),
this.location.getPitch()));
// Position found and player can is already teleported to it.
return true;
}
else if (this.noPortalPosition == null)
{
// Mark first incoming position as the best for teleportation.
this.noPortalPosition = new Location(this.world,
positionData.vector().getBlockX() + 0.5,
positionData.vector().getBlockY() + 0.1,
positionData.vector().getBlockZ() + 0.5,
this.location.getYaw(),
this.location.getPitch());
}
}
else
{
// First best position should be valid for teleportation.
this.teleportEntity(new Location(this.world,
positionData.vector().getBlockX() + 0.5,
positionData.vector().getBlockY() + 0.1,
positionData.vector().getBlockZ() + 0.5,
this.location.getYaw(),
this.location.getPitch()));
return true;
}
return false;
}
/**
* PositionData record holds information about position where player will be teleported.
* @param vector Vector of the position.
* @param distance Distance till the position.
* @param block Block on which player will be placed.
* @param spaceOne One block above block.
* @param spaceTwo Two blocks above block.
*/
private record PositionData(Vector vector, Material block, Material spaceOne, Material spaceTwo, double distance) {}
public static Builder builder(BentoBox plugin)
{
return new Builder(plugin);
}
// ---------------------------------------------------------------------
// Section: Builder
// ---------------------------------------------------------------------
public static class Builder
{
private Builder(BentoBox plugin)
{
this.plugin = plugin;
this.result = new CompletableFuture<>();
}
// ---------------------------------------------------------------------
// Section: Builders
// ---------------------------------------------------------------------
/**
* Set who or what is going to teleport
*
* @param entity entity to teleport
* @return Builder
*/
public Builder entity(Entity entity)
{
this.entity = entity;
return this;
}
/**
* Set the desired location
*
* @param location the location
* @return Builder
*/
public Builder location(Location location)
{
this.location = location;
return this;
}
/**
* This is a portal teleportation
*
* @return Builder
*/
public Builder portal()
{
this.portal = true;
return this;
}
/**
* This is a successRunnable for teleportation
*
* @return Builder
*/
public Builder successRunnable(Runnable successRunnable)
{
this.successRunnable = successRunnable;
return this;
}
/**
* Try to teleport the player
*
* @return ClosestSafeSpotTeleport
*/
@Nullable
public ClosestSafeSpotTeleport build()
{
// Error checking
if (this.entity == null)
{
this.plugin.logError("Attempt to safe teleport a null entity!");
this.result.complete(null);
return null;
}
if (this.location == null)
{
this.plugin.logError("Attempt to safe teleport to a null location!");
this.result.complete(null);
return null;
}
if (this.location.getWorld() == null)
{
this.plugin.logError("Attempt to safe teleport to a null world!");
this.result.complete(null);
return null;
}
if (this.failureMessage.isEmpty() && this.entity instanceof Player)
{
this.failureMessage = "general.errors.no-safe-location-found";
}
return new ClosestSafeSpotTeleport(this);
}
// ---------------------------------------------------------------------
// Section: Getters
// ---------------------------------------------------------------------
/**
* Gets plugin.
*
* @return the plugin
*/
public BentoBox getPlugin()
{
return this.plugin;
}
/**
* Gets result.
*
* @return the result
*/
public CompletableFuture<Boolean> getResult()
{
return this.result;
}
/**
* Gets entity.
*
* @return the entity
*/
public Entity getEntity()
{
return this.entity;
}
/**
* Gets location.
*
* @return the location
*/
public Location getLocation()
{
return this.location;
}
/**
* Gets world.
*
* @return the world
*/
public World getWorld()
{
return this.world;
}
/**
* Gets success runnable.
*
* @return the success runnable
*/
public Runnable getSuccessRunnable()
{
return this.successRunnable;
}
/**
* Gets fail runnable.
*
* @return the fail runnable
*/
public Runnable getFailRunnable()
{
return this.failRunnable;
}
/**
* Gets failure message.
*
* @return the failure message
*/
public String getFailureMessage()
{
return this.failureMessage;
}
/**
* Is portal boolean.
*
* @return the boolean
*/
public boolean isPortal()
{
return this.portal;
}
/**
* Is cancel if fail boolean.
*
* @return the boolean
*/
public boolean isCancelIfFail()
{
return this.cancelIfFail;
}
// ---------------------------------------------------------------------
// Section: Variables
// ---------------------------------------------------------------------
/**
* BentoBox plugin instance.
*/
private final BentoBox plugin;
/**
* CompletableFuture that is triggered upon finishing position searching.
*/
private final CompletableFuture<Boolean> result;
/**
* Entity that will be teleported.
*/
private Entity entity;
/**
* Start location of teleportation.
*/
private Location location;
/**
* World where teleportation happens.
*/
private World world;
/**
* Runnable that will be triggered after successful teleportation.
*/
private Runnable successRunnable;
/**
* Runnable that will be triggered after failing teleportation.
*/
private Runnable failRunnable;
/**
* Stores the failure message that is sent to a player.
*/
private String failureMessage = "";
/**
* Boolean that indicates if teleportation should search for portal.
*/
private boolean portal;
/**
* Boolean that indicates if failing teleport should cancel it or create spot for player.
*/
private boolean cancelIfFail;
}
// ---------------------------------------------------------------------
// Section: Constants
// ---------------------------------------------------------------------
/**
* This comparator sorts position data based in order:
* - the smallest distance value
* - the smallest x value
* - the smallest z value
* - the smallest y value
*/
private final static Comparator<PositionData> POSITION_COMPARATOR = Comparator.comparingDouble(PositionData::distance).
thenComparingInt(position -> position.vector().getBlockX()).
thenComparingInt(position -> position.vector().getBlockZ()).
thenComparingInt(position -> position.vector().getBlockY());
/**
* Stores chunk load speed.
*/
private static final long CHUNK_LOAD_SPEED = 1;
// ---------------------------------------------------------------------
// Section: Variables
// ---------------------------------------------------------------------
/**
* BentoBox plugin instance.
*/
private final BentoBox plugin;
/**
* Entity that will be teleported.
*/
private final Entity entity;
/**
* Start location of teleportation.
*/
private final Location location;
/**
* World where teleportation happens.
*/
private final World world;
/**
* Runnable that will be triggered after successful teleportation.
*/
private final Runnable successRunnable;
/**
* Runnable that will be triggered after failing teleportation.
*/
private final Runnable failRunnable;
/**
* Stores the failure message that is sent to a player.
*/
private final String failureMessage;
/**
* CompletableFuture that is triggered upon finishing position searching.
*/
private final CompletableFuture<Boolean> result;
/**
* Boolean that indicates if teleportation should search for portal.
*/
private final boolean portal;
/**
* Boolean that indicates if failing teleport should cancel it or create spot for player.
*/
private final boolean cancelIfFail;
/**
* Local variable that indicates if current process is running.
*/
private final AtomicBoolean checking = new AtomicBoolean();
/**
* The distance from starting location in all directions where new position will be searched.
*/
private int range;
/**
* Block Queue for all blocks that should be validated.
*/
private Queue<PositionData> blockQueue;
/**
* List of chunks that will be scanned for positions.
*/
private Iterator<Pair<Integer, Integer>> chunksToScanIterator;
/**
* BoundingBox where teleportation can happen. Areas outside are illegal.
*/
private BoundingBox boundingBox;
/**
* This method returns first best available spot if portal was not found in search area.
*/
private Location noPortalPosition;
/**
* Bukkit task that processes chunks.
*/
private BukkitTask task;
}