feat(pocket-base): add reusable keystone travel
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public record KeystoneReturnLocation(
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
float yaw,
|
||||
float pitch
|
||||
) {
|
||||
public KeystoneReturnLocation {
|
||||
Objects.requireNonNull(worldId, "worldId");
|
||||
Objects.requireNonNull(worldName, "worldName");
|
||||
if (worldName.isBlank()) {
|
||||
throw new IllegalArgumentException("worldName must not be blank");
|
||||
}
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||
throw new IllegalArgumentException("return coordinates must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class KeystoneReturnStore {
|
||||
private final YamlKeystoneReturnRepository repository;
|
||||
private final Map<UUID, KeystoneReturnLocation> destinations;
|
||||
|
||||
KeystoneReturnStore(YamlKeystoneReturnRepository repository) throws IOException {
|
||||
this.repository = repository;
|
||||
this.destinations = new HashMap<>(repository.load());
|
||||
}
|
||||
|
||||
Optional<KeystoneReturnLocation> destination(UUID playerId) {
|
||||
return Optional.ofNullable(destinations.get(playerId));
|
||||
}
|
||||
|
||||
void record(UUID playerId, KeystoneReturnLocation destination) throws IOException {
|
||||
KeystoneReturnLocation previous = destinations.put(playerId, destination);
|
||||
try {
|
||||
repository.save(destinations);
|
||||
} catch (IOException exception) {
|
||||
if (previous == null) {
|
||||
destinations.remove(playerId);
|
||||
} else {
|
||||
destinations.put(playerId, previous);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.inventory.meta.CompassMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
final class PocketBaseKeystoneService implements Listener {
|
||||
private static final Set<Material> RETURN_HAZARDS = Set.of(
|
||||
Material.LAVA,
|
||||
Material.FIRE,
|
||||
Material.SOUL_FIRE,
|
||||
Material.MAGMA_BLOCK,
|
||||
Material.CACTUS,
|
||||
Material.CAMPFIRE,
|
||||
Material.SOUL_CAMPFIRE,
|
||||
Material.POWDER_SNOW
|
||||
);
|
||||
private final Server server;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final KeystoneReturnStore returns;
|
||||
private final NamespacedKey markerKey;
|
||||
private final Logger logger;
|
||||
|
||||
PocketBaseKeystoneService(
|
||||
Server server,
|
||||
PocketBaseManager pocketBases,
|
||||
KeystoneReturnStore returns,
|
||||
NamespacedKey markerKey,
|
||||
Logger logger
|
||||
) {
|
||||
this.server = server;
|
||||
this.pocketBases = pocketBases;
|
||||
this.returns = returns;
|
||||
this.markerKey = markerKey;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH)
|
||||
public void onPrepareCraft(PrepareItemCraftEvent event) {
|
||||
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|
||||
|| !shaped.getKey().equals(markerKey)
|
||||
|| !(event.getView().getPlayer() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
if (pocketBases.state(player.getUniqueId()).level() < 1) {
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onUse(PlayerInteractEvent event) {
|
||||
Action action = event.getAction();
|
||||
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|
||||
|| !isKeystone(event.getItem(), markerKey)) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
Player player = event.getPlayer();
|
||||
UUID playerId = player.getUniqueId();
|
||||
if (pocketBases.state(playerId).level() < 1) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Unlock Pocket Base I before using a Pocket Base Keystone.");
|
||||
return;
|
||||
}
|
||||
Optional<UUID> currentPocket = pocketBases.ownerForPocketWorld(
|
||||
player.getWorld().getUID()
|
||||
);
|
||||
if (currentPocket.isPresent()) {
|
||||
if (!currentPocket.orElseThrow().equals(playerId)) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "You can only use a keystone to leave your own Pocket Base.");
|
||||
return;
|
||||
}
|
||||
returnToSavedDestination(player);
|
||||
return;
|
||||
}
|
||||
Location origin = player.getLocation();
|
||||
World originWorld = origin.getWorld();
|
||||
if (originWorld == null) {
|
||||
player.sendMessage(ChatColor.RED + "Your return location could not be recorded.");
|
||||
return;
|
||||
}
|
||||
KeystoneReturnLocation destination = new KeystoneReturnLocation(
|
||||
originWorld.getUID(),
|
||||
originWorld.getName(),
|
||||
origin.getX(),
|
||||
origin.getY(),
|
||||
origin.getZ(),
|
||||
origin.getYaw(),
|
||||
origin.getPitch()
|
||||
);
|
||||
try {
|
||||
returns.record(playerId, destination);
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.SEVERE, "Could not save a Pocket Base Keystone return", exception);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Your return location could not be saved, so you were not teleported.");
|
||||
return;
|
||||
}
|
||||
teleport(player, pocketBases.pocketArrival(playerId));
|
||||
}
|
||||
|
||||
private void returnToSavedDestination(Player player) {
|
||||
Optional<KeystoneReturnLocation> saved = returns.destination(player.getUniqueId());
|
||||
if (saved.isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + "No Pocket Base Keystone return is recorded.");
|
||||
return;
|
||||
}
|
||||
KeystoneReturnLocation destination = saved.orElseThrow();
|
||||
World world = server.getWorld(destination.worldId());
|
||||
if (world == null) {
|
||||
player.sendMessage(ChatColor.RED + "Your recorded return world is unavailable.");
|
||||
return;
|
||||
}
|
||||
Location location = new Location(
|
||||
world,
|
||||
destination.x(),
|
||||
destination.y(),
|
||||
destination.z(),
|
||||
destination.yaw(),
|
||||
destination.pitch()
|
||||
);
|
||||
if (!isSafe(location)) {
|
||||
player.sendMessage(ChatColor.RED + "Your recorded return location is not safe.");
|
||||
return;
|
||||
}
|
||||
teleport(player, location);
|
||||
}
|
||||
|
||||
private static void teleport(Player player, Location destination) {
|
||||
float previousFallDistance = player.getFallDistance();
|
||||
player.setFallDistance(0.0F);
|
||||
if (!player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)) {
|
||||
player.setFallDistance(previousFallDistance);
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base Keystone travel failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSafe(Location location) {
|
||||
World world = location.getWorld();
|
||||
int x = location.getBlockX();
|
||||
int y = location.getBlockY();
|
||||
int z = location.getBlockZ();
|
||||
if (world == null || y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
|
||||
return false;
|
||||
}
|
||||
Block ground = world.getBlockAt(x, y - 1, z);
|
||||
Block feet = world.getBlockAt(x, y, z);
|
||||
Block head = world.getBlockAt(x, y + 1, z);
|
||||
return feet.isPassable() && head.isPassable()
|
||||
&& !feet.isLiquid() && !head.isLiquid()
|
||||
&& !ground.isPassable() && !ground.isLiquid()
|
||||
&& !RETURN_HAZARDS.contains(ground.getType())
|
||||
&& !RETURN_HAZARDS.contains(feet.getType())
|
||||
&& !RETURN_HAZARDS.contains(head.getType());
|
||||
}
|
||||
|
||||
static boolean isKeystone(ItemStack item, NamespacedKey markerKey) {
|
||||
if (item == null || item.getType() != Material.RECOVERY_COMPASS) {
|
||||
return false;
|
||||
}
|
||||
ItemMeta metadata = item.getItemMeta();
|
||||
if (metadata == null) {
|
||||
return false;
|
||||
}
|
||||
Byte marker = metadata.getPersistentDataContainer().get(
|
||||
markerKey, PersistentDataType.BYTE
|
||||
);
|
||||
return marker != null && marker == (byte) 1;
|
||||
}
|
||||
|
||||
void registerRecipe() {
|
||||
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
|
||||
}
|
||||
|
||||
static ItemStack createKeystone(NamespacedKey markerKey) {
|
||||
ItemStack item = new ItemStack(Material.RECOVERY_COMPASS);
|
||||
ItemMeta metadata = item.getItemMeta();
|
||||
if (!(metadata instanceof CompassMeta)) {
|
||||
throw new IllegalStateException("Recovery Compass metadata is unavailable");
|
||||
}
|
||||
metadata.displayName(Component.text(
|
||||
"Pocket Base Keystone", NamedTextColor.LIGHT_PURPLE
|
||||
));
|
||||
metadata.lore(List.of(
|
||||
Component.text(
|
||||
"Right-click to enter your Pocket Base.", NamedTextColor.GRAY
|
||||
),
|
||||
Component.text("Use it again inside to return.", NamedTextColor.GRAY)
|
||||
));
|
||||
metadata.getPersistentDataContainer().set(
|
||||
markerKey, PersistentDataType.BYTE, (byte) 1
|
||||
);
|
||||
item.setItemMeta(metadata);
|
||||
return item;
|
||||
}
|
||||
|
||||
static RecipeSpec recipeSpecification() {
|
||||
return new RecipeSpec(
|
||||
"ONO", "NEN", "ONO", Material.OBSIDIAN,
|
||||
Material.NETHERITE_INGOT, Material.ENDER_PEARL
|
||||
);
|
||||
}
|
||||
|
||||
static ShapedRecipe recipe(NamespacedKey key, ItemStack result) {
|
||||
RecipeSpec specification = recipeSpecification();
|
||||
ShapedRecipe recipe = new ShapedRecipe(key, result);
|
||||
recipe.shape(specification.top(), specification.middle(), specification.bottom());
|
||||
recipe.setIngredient('O', specification.obsidian());
|
||||
recipe.setIngredient('N', specification.netherite());
|
||||
recipe.setIngredient('E', specification.enderPearl());
|
||||
return recipe;
|
||||
}
|
||||
|
||||
record RecipeSpec(
|
||||
String top,
|
||||
String middle,
|
||||
String bottom,
|
||||
Material obsidian,
|
||||
Material netherite,
|
||||
Material enderPearl
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
@@ -16,6 +17,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private BaseTeleportManager teleportManager;
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
private PocketBaseKeystoneService keystoneService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -40,6 +42,17 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
settingsProvider
|
||||
);
|
||||
pocketBaseManager.loadWorlds();
|
||||
NamespacedKey keystoneKey = new NamespacedKey(this, "pocket_base_keystone");
|
||||
keystoneService = new PocketBaseKeystoneService(
|
||||
getServer(),
|
||||
pocketBaseManager,
|
||||
new KeystoneReturnStore(new YamlKeystoneReturnRepository(
|
||||
getDataFolder().toPath().resolve("keystone-returns.yml")
|
||||
)),
|
||||
keystoneKey,
|
||||
getLogger()
|
||||
);
|
||||
keystoneService.registerRecipe();
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
@@ -89,6 +102,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(flightController, this);
|
||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||
getServer().getPluginManager().registerEvents(pocketBaseController, this);
|
||||
getServer().getPluginManager().registerEvents(keystoneService, this);
|
||||
|
||||
command("setbase").setExecutor(new SetBaseCommand(
|
||||
stateManager, baseService, Clock.systemUTC(), pocketBaseController
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
public final class YamlKeystoneReturnRepository {
|
||||
private final Path stateFile;
|
||||
|
||||
public YamlKeystoneReturnRepository(Path stateFile) {
|
||||
this.stateFile = stateFile;
|
||||
}
|
||||
|
||||
public Map<UUID, KeystoneReturnLocation> load() throws IOException {
|
||||
if (!Files.exists(stateFile)) {
|
||||
return Map.of();
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
try {
|
||||
yaml.load(stateFile.toFile());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Pocket Base Keystone returns are not valid YAML", exception);
|
||||
}
|
||||
Map<UUID, KeystoneReturnLocation> destinations = new HashMap<>();
|
||||
ConfigurationSection players = yaml.getConfigurationSection("players");
|
||||
if (players == null) {
|
||||
return destinations;
|
||||
}
|
||||
for (String key : players.getKeys(false)) {
|
||||
try {
|
||||
String path = "players." + key;
|
||||
String worldId = yaml.getString(path + ".world-id");
|
||||
String worldName = yaml.getString(path + ".world-name");
|
||||
if (worldId == null || worldName == null) {
|
||||
continue;
|
||||
}
|
||||
destinations.put(UUID.fromString(key), new KeystoneReturnLocation(
|
||||
UUID.fromString(worldId),
|
||||
worldName,
|
||||
yaml.getDouble(path + ".x"),
|
||||
yaml.getDouble(path + ".y"),
|
||||
yaml.getDouble(path + ".z"),
|
||||
(float) yaml.getDouble(path + ".yaw"),
|
||||
(float) yaml.getDouble(path + ".pitch")
|
||||
));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid records do not provide a return destination.
|
||||
}
|
||||
}
|
||||
return destinations;
|
||||
}
|
||||
|
||||
public void save(Map<UUID, KeystoneReturnLocation> destinations) throws IOException {
|
||||
Path parent = stateFile.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
for (Map.Entry<UUID, KeystoneReturnLocation> entry : destinations.entrySet()) {
|
||||
String path = "players." + entry.getKey();
|
||||
KeystoneReturnLocation destination = entry.getValue();
|
||||
yaml.set(path + ".world-id", destination.worldId().toString());
|
||||
yaml.set(path + ".world-name", destination.worldName());
|
||||
yaml.set(path + ".x", destination.x());
|
||||
yaml.set(path + ".y", destination.y());
|
||||
yaml.set(path + ".z", destination.z());
|
||||
yaml.set(path + ".yaw", destination.yaw());
|
||||
yaml.set(path + ".pitch", destination.pitch());
|
||||
}
|
||||
Path temporary = Files.createTempFile(parent, "spigot-base-keystone-", ".yml");
|
||||
try {
|
||||
yaml.save(temporary.toFile());
|
||||
try {
|
||||
Files.move(
|
||||
temporary,
|
||||
stateFile,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
);
|
||||
} catch (IOException atomicMoveFailure) {
|
||||
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user