Initial Stargate plugin: Paper gate plugin + Velocity/Bungee cross-server bridges

This commit is contained in:
Michael Burgess
2026-08-09 09:53:55 -04:00
commit 83a0392ee9
34 changed files with 2317 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
plugins {
id("com.gradleup.shadow") version "8.3.5"
}
dependencies {
implementation(project(":stargate-common"))
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("org.xerial:sqlite-jdbc:3.46.1.3")
implementation("com.mysql:mysql-connector-j:8.4.0")
}
tasks.shadowJar {
archiveClassifier.set("")
relocate("com.zaxxer.hikari", "dev.skywalker3200.stargate.libs.hikari")
relocate("org.sqlite", "dev.skywalker3200.stargate.libs.sqlite")
relocate("com.mysql", "dev.skywalker3200.stargate.libs.mysql")
}
tasks.build {
dependsOn(tasks.shadowJar)
}
tasks.processResources {
filesMatching("plugin.yml") {
expand("version" to project.version)
}
}
@@ -0,0 +1,96 @@
package dev.skywalker3200.stargate.paper;
import dev.skywalker3200.stargate.common.storage.GateStorage;
import dev.skywalker3200.stargate.common.storage.SqlGateStorage;
import dev.skywalker3200.stargate.paper.command.StargateCommand;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.listener.SignInteractListener;
import dev.skywalker3200.stargate.paper.listener.SignCreateListener;
import dev.skywalker3200.stargate.paper.listener.StructureProtectListener;
import dev.skywalker3200.stargate.paper.network.CrossServerBridge;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
public class StargatePlugin extends JavaPlugin {
private GateStorage storage;
private GateManager gateManager;
private CrossServerBridge crossServerBridge;
private String serverId;
@Override
public void onEnable() {
saveDefaultConfig();
this.serverId = getConfig().getString("server-id", "server1");
try {
this.storage = buildStorage();
this.storage.init();
} catch (Exception e) {
getLogger().severe("Failed to initialise storage, disabling Stargate: " + e.getMessage());
e.printStackTrace();
getServer().getPluginManager().disablePlugin(this);
return;
}
this.gateManager = new GateManager(this, storage);
this.gateManager.loadAll();
boolean crossServer = getConfig().getBoolean("cross-server.enabled", false);
this.crossServerBridge = new CrossServerBridge(this, gateManager);
if (crossServer) {
this.crossServerBridge.register();
}
getServer().getPluginManager().registerEvents(new SignCreateListener(this, gateManager), this);
getServer().getPluginManager().registerEvents(new SignInteractListener(this, gateManager, crossServerBridge), this);
getServer().getPluginManager().registerEvents(new StructureProtectListener(gateManager), this);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateTeleportListener(this, gateManager, crossServerBridge), this);
StargateCommand command = new StargateCommand(this, gateManager);
getCommand("stargate").setExecutor(command);
getCommand("stargate").setTabCompleter(command);
getLogger().info("Stargate enabled. server-id=" + serverId + " cross-server=" + crossServer);
}
@Override
public void onDisable() {
if (gateManager != null) {
gateManager.closeAllGates();
}
if (storage != null) {
storage.close();
}
}
private GateStorage buildStorage() {
String type = getConfig().getString("storage.type", "sqlite").toLowerCase();
if (type.equals("mysql")) {
String host = getConfig().getString("storage.mysql.host", "localhost");
int port = getConfig().getInt("storage.mysql.port", 3306);
String db = getConfig().getString("storage.mysql.database", "stargate");
String user = getConfig().getString("storage.mysql.username", "stargate");
String pass = getConfig().getString("storage.mysql.password", "");
String prefix = getConfig().getString("storage.mysql.table-prefix", "stargate_");
String url = "jdbc:mysql://" + host + ":" + port + "/" + db + "?useSSL=false&autoReconnect=true";
return new SqlGateStorage(SqlGateStorage.Driver.MYSQL, url, user, pass, prefix, getLogger());
}
File dataFile = new File(getDataFolder(), "gates.db");
String url = "jdbc:sqlite:" + dataFile.getAbsolutePath();
return new SqlGateStorage(SqlGateStorage.Driver.SQLITE, url, null, null, "stargate_", getLogger());
}
public String getServerId() {
return serverId;
}
public GateManager getGateManager() {
return gateManager;
}
public CrossServerBridge getCrossServerBridge() {
return crossServerBridge;
}
}
@@ -0,0 +1,91 @@
package dev.skywalker3200.stargate.paper.command;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.block.Block;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.stream.Collectors;
public class StargateCommand implements CommandExecutor, TabCompleter {
private final StargatePlugin plugin;
private final GateManager gateManager;
public StargateCommand(StargatePlugin plugin, GateManager gateManager) {
this.plugin = plugin;
this.gateManager = gateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
sender.sendMessage(Component.text("Usage: /sg <list|destroy|reload>", NamedTextColor.YELLOW));
return true;
}
switch (args[0].toLowerCase()) {
case "reload" -> {
if (!sender.hasPermission("stargate.admin")) {
sender.sendMessage(Component.text("No permission.", NamedTextColor.RED));
return true;
}
plugin.reloadConfig();
gateManager.reloadConfig();
gateManager.loadAll();
sender.sendMessage(Component.text("Stargate reloaded.", NamedTextColor.GREEN));
}
case "list" -> {
String network = args.length > 1 ? args[1] : gateManager.getConfig().defaultNetwork;
List<RuntimeGate> gates = gateManager.getNetwork(network);
sender.sendMessage(Component.text("Network '" + network + "' (" + gates.size() + " gate(s)):", NamedTextColor.AQUA));
for (RuntimeGate rg : gates) {
sender.sendMessage(Component.text(" - " + rg.getGate().getName() + " @ " + rg.getGate().getServerId()
+ "/" + rg.getGate().getWorld(), NamedTextColor.GRAY));
}
}
case "destroy" -> {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("Players only.", NamedTextColor.RED));
return true;
}
Block target = player.getTargetBlockExact(6);
RuntimeGate rg = target == null ? null : gateManager.getBySign(target);
if (rg == null) {
player.sendMessage(Component.text("Look at a stargate sign to destroy it.", NamedTextColor.RED));
return true;
}
boolean allowed = player.hasPermission("stargate.admin")
|| (rg.getGate().getOwner() != null && rg.getGate().getOwner().equals(player.getUniqueId()) && player.hasPermission("stargate.destroy"));
if (!allowed) {
player.sendMessage(Component.text("You can't destroy this stargate.", NamedTextColor.RED));
return true;
}
gateManager.destroyGate(rg);
player.sendMessage(Component.text("Destroyed '" + rg.getGate().getName() + "'.", NamedTextColor.YELLOW));
}
default -> sender.sendMessage(Component.text("Unknown subcommand.", NamedTextColor.RED));
}
return true;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
return List.of("list", "destroy", "reload").stream()
.filter(s -> s.startsWith(args[0].toLowerCase()))
.collect(Collectors.toList());
}
return new ArrayList<>();
}
}
@@ -0,0 +1,56 @@
package dev.skywalker3200.stargate.paper.gate;
import org.bukkit.Material;
import org.bukkit.configuration.file.FileConfiguration;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
/** Typed view over the `gate:` / `dialing:` / `network:` sections of config.yml. */
public class GateConfig {
public final Set<Material> frameMaterials;
public final Material chevronUnlit;
public final Material chevronLit;
public final Material irisMaterial;
public final int maxFrameBlocks;
public final int maxIrisBlocks;
public final int minFrameBlocks;
public final int openSeconds;
public final int chevronTickDelay;
public final boolean playSounds;
public final String defaultNetwork;
public GateConfig(FileConfiguration cfg, Logger logger) {
Set<Material> materials = new HashSet<>();
for (String s : cfg.getStringList("gate.frame-materials")) {
Material m = Material.matchMaterial(s);
if (m != null) materials.add(m);
else logger.warning("[Stargate] Unknown frame material in config: " + s);
}
if (materials.isEmpty()) materials.add(Material.OBSIDIAN);
this.frameMaterials = materials;
this.chevronUnlit = matOr(cfg.getString("gate.chevron-unlit-material"), Material.BLACK_STAINED_GLASS, logger);
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GLOWSTONE, logger);
this.irisMaterial = matOr(cfg.getString("gate.iris-material"), Material.WATER, logger);
this.maxFrameBlocks = cfg.getInt("gate.max-frame-blocks", 300);
this.maxIrisBlocks = cfg.getInt("gate.max-iris-blocks", 200);
this.minFrameBlocks = cfg.getInt("gate.min-frame-blocks", 8);
this.openSeconds = cfg.getInt("dialing.open-seconds", 10);
this.chevronTickDelay = cfg.getInt("dialing.chevron-tick-delay", 4);
this.playSounds = cfg.getBoolean("dialing.play-sounds", true);
this.defaultNetwork = cfg.getString("network.default-network", "main");
}
private Material matOr(String s, Material fallback, Logger logger) {
if (s == null) return fallback;
Material m = Material.matchMaterial(s);
if (m == null) {
logger.warning("[Stargate] Unknown material in config: " + s + ", using " + fallback);
return fallback;
}
return m;
}
}
@@ -0,0 +1,272 @@
package dev.skywalker3200.stargate.paper.gate;
import dev.skywalker3200.stargate.common.model.Gate;
import dev.skywalker3200.stargate.common.storage.GateStorage;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.Levelled;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/** Owns every gate known to this server: creation, lookup, dialing/animation, and teardown. */
public class GateManager {
private final StargatePlugin plugin;
private final GateStorage storage;
private GateConfig config;
private GateStructureScanner scanner;
private final Map<UUID, RuntimeGate> gatesById = new HashMap<>();
private final Map<String, RuntimeGate> gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate
public GateManager(StargatePlugin plugin, GateStorage storage) {
this.plugin = plugin;
this.storage = storage;
reloadConfig();
}
public void reloadConfig() {
this.config = new GateConfig(plugin.getConfig(), plugin.getLogger());
this.scanner = new GateStructureScanner(config.frameMaterials, config.chevronUnlit, config.chevronLit,
config.maxFrameBlocks, config.maxIrisBlocks, config.minFrameBlocks);
}
public GateConfig getConfig() { return config; }
public void loadAll() {
gatesById.clear();
gatesBySignBlock.clear();
for (Gate gate : storage.loadAll()) {
GateStructure structure = null;
if (gate.getServerId().equals(plugin.getServerId())) {
World world = Bukkit.getWorld(gate.getSignWorld());
if (world != null) {
Block signBlock = world.getBlockAt(gate.getSignX(), gate.getSignY(), gate.getSignZ());
Block attached = attachedFrameBlock(signBlock);
if (attached != null) {
structure = scanner.scan(attached);
}
if (structure == null) {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName() + "' - it may have been damaged.");
}
}
}
RuntimeGate rg = new RuntimeGate(gate, structure);
gatesById.put(gate.getId(), rg);
if (gate.getServerId().equals(plugin.getServerId())) {
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
}
}
plugin.getLogger().info("[Stargate] Loaded " + gatesById.size() + " gate(s), " + gatesBySignBlock.size() + " local.");
}
/** Given the sign block, finds the neighbouring block that belongs to the frame (the wall it's mounted on, or the block below for a sign post). */
public Block attachedFrameBlock(Block signBlock) {
org.bukkit.block.BlockState state = signBlock.getState();
if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) {
return signBlock.getRelative(wallSign.getFacing().getOppositeFace());
}
// sign post or other: just probe all neighbours, scanner will validate
return signBlock.getRelative(org.bukkit.block.BlockFace.DOWN);
}
private String signKey(String world, int x, int y, int z) {
return world + "," + x + "," + y + "," + z;
}
public RuntimeGate getBySign(Block signBlock) {
return gatesBySignBlock.get(signKey(signBlock.getWorld().getName(), signBlock.getX(), signBlock.getY(), signBlock.getZ()));
}
public RuntimeGate getById(UUID id) { return gatesById.get(id); }
public List<RuntimeGate> getNetwork(String network) {
return gatesById.values().stream()
.filter(g -> g.getGate().getNetwork().equalsIgnoreCase(network))
.collect(Collectors.toList());
}
public List<RuntimeGate> all() { return new ArrayList<>(gatesById.values()); }
/** Other dialable gates in the same network, excluding this one and hidden ones (owner can still see their own hidden gates). */
public List<RuntimeGate> destinationsFor(RuntimeGate from) {
return getNetwork(from.getGate().getNetwork()).stream()
.filter(g -> !g.getGate().getId().equals(from.getGate().getId()))
.filter(g -> !g.getGate().isHidden())
.sorted((a, b) -> a.getGate().getName().compareToIgnoreCase(b.getGate().getName()))
.collect(Collectors.toList());
}
public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags) {
Block attached = attachedFrameBlock(signBlock);
if (attached == null) return null;
GateStructure structure = scanner.scan(attached);
if (structure == null) return null;
Location exit = computeExitLocation(structure, signBlock);
Gate gate = new Gate(UUID.randomUUID(), name, network, plugin.getServerId(),
exit.getWorld().getName(), exit.getBlockX(), exit.getBlockY(), exit.getBlockZ(), exit.getYaw(),
signBlock.getX(), signBlock.getY(), signBlock.getZ(), signBlock.getWorld().getName(),
signBlockFacing(signBlock), owner, flags, null);
RuntimeGate rg = new RuntimeGate(gate, structure);
gatesById.put(gate.getId(), rg);
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
storage.saveGate(gate);
return rg;
}
public void destroyGate(RuntimeGate rg) {
closeGate(rg);
gatesById.remove(rg.getGate().getId());
gatesBySignBlock.remove(signKey(rg.getGate().getSignWorld(), rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ()));
storage.deleteGate(rg.getGate().getId());
}
public void saveGate(RuntimeGate rg) {
storage.saveGate(rg.getGate());
}
private Location computeExitLocation(GateStructure structure, Block signBlock) {
List<Block> iris = structure.getIris();
World world = signBlock.getWorld();
if (iris.isEmpty()) {
return signBlock.getLocation().add(0, 0, 0);
}
long sumX = 0, sumZ = 0;
int minY = Integer.MAX_VALUE;
for (Block b : iris) {
sumX += b.getX();
sumZ += b.getZ();
minY = Math.min(minY, b.getY());
}
double avgX = (double) sumX / iris.size() + 0.5;
double avgZ = (double) sumZ / iris.size() + 0.5;
float yaw = 0f;
org.bukkit.block.BlockState state = signBlock.getState();
if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) {
// face away from the wall the sign is mounted on, into the room the sign faces
yaw = faceToYaw(wallSign.getFacing());
}
return new Location(world, avgX, minY + 1, avgZ, yaw, 0f);
}
private float faceToYaw(org.bukkit.block.BlockFace face) {
return switch (face) {
case NORTH -> 180f;
case SOUTH -> 0f;
case EAST -> -90f;
case WEST -> 90f;
default -> 0f;
};
}
private String signBlockFacing(Block signBlock) {
org.bukkit.block.BlockState state = signBlock.getState();
if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) {
return wallSign.getFacing().name();
}
return "SELF";
}
// ---- Dialing ----
/** Starts the chevron-lighting animation, then opens the gate and connects it to the destination. */
public void dial(RuntimeGate from, RuntimeGate to, Player initiator) {
if (from.isOpen()) closeGate(from);
if (to.isOpen()) closeGate(to);
List<Block> chevrons = from.getStructure() != null ? from.getStructure().getChevrons() : List.of();
int delay = Math.max(1, config.chevronTickDelay);
Runnable finish = () -> {
openGate(from, to);
openGate(to, from);
};
if (chevrons.isEmpty()) {
finish.run();
return;
}
final int[] i = {0};
final org.bukkit.scheduler.BukkitTask[] taskHolder = new org.bukkit.scheduler.BukkitTask[1];
taskHolder[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
if (i[0] >= chevrons.size()) {
taskHolder[0].cancel();
finish.run();
return;
}
Block chevron = chevrons.get(i[0]);
chevron.setType(config.chevronLit);
if (config.playSounds) {
chevron.getWorld().playSound(chevron.getLocation(), Sound.BLOCK_STONE_STEP, 1f, 1.4f);
}
i[0]++;
}, 0L, delay);
from.setDialTask(taskHolder[0]);
}
public void openGate(RuntimeGate gate, RuntimeGate connectedTo) {
gate.setOpen(true);
gate.setConnectedTo(connectedTo);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(config.irisMaterial);
if (b.getBlockData() instanceof Levelled lvl) {
lvl.setLevel(0);
b.setBlockData(lvl);
}
}
for (Block c : gate.getStructure().getChevrons()) {
c.setType(config.chevronLit);
}
}
if (config.playSounds) {
World w = Bukkit.getWorld(gate.getGate().getWorld());
if (w != null) {
w.playSound(new Location(w, gate.getGate().getExitX(), gate.getGate().getExitY(), gate.getGate().getExitZ()),
Sound.ENTITY_GENERIC_EXPLODE, 0.5f, 1.8f);
}
}
if (gate.getCloseTask() != null) gate.getCloseTask().cancel();
var task = Bukkit.getScheduler().runTaskLater(plugin, () -> closeGate(gate), config.openSeconds * 20L);
gate.setCloseTask(task);
}
public void closeGate(RuntimeGate gate) {
if (gate.getDialTask() != null) { gate.getDialTask().cancel(); gate.setDialTask(null); }
if (gate.getCloseTask() != null) { gate.getCloseTask().cancel(); gate.setCloseTask(null); }
gate.setOpen(false);
RuntimeGate other = gate.getConnectedTo();
gate.setConnectedTo(null);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(org.bukkit.Material.AIR);
}
for (Block c : gate.getStructure().getChevrons()) {
c.setType(config.chevronUnlit);
}
}
if (other != null && other.isOpen()) {
closeGate(other);
}
}
public void closeAllGates() {
for (RuntimeGate rg : new ArrayList<>(gatesById.values())) {
if (rg.isOpen()) closeGate(rg);
}
}
}
@@ -0,0 +1,22 @@
package dev.skywalker3200.stargate.paper.gate;
import org.bukkit.block.Block;
import java.util.List;
/** The physical blocks that make up a scanned gate: frame ring, chevrons, and interior iris. */
public class GateStructure {
private final List<Block> frame;
private final List<Block> chevrons;
private final List<Block> iris;
public GateStructure(List<Block> frame, List<Block> chevrons, List<Block> iris) {
this.frame = frame;
this.chevrons = chevrons;
this.iris = iris;
}
public List<Block> getFrame() { return frame; }
public List<Block> getChevrons() { return chevrons; }
public List<Block> getIris() { return iris; }
}
@@ -0,0 +1,143 @@
package dev.skywalker3200.stargate.paper.gate;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Flood-fill scanner that discovers a gate's physical structure starting from the block
* a control sign is attached to: the frame ring, the chevron blocks embedded in it, and
* the enclosed interior ("iris") that gets filled with water while the gate is open.
*/
public class GateStructureScanner {
private static final BlockFace[] NEIGHBORS = {
BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
};
private final Set<Material> frameMaterials;
private final Material chevronUnlit;
private final Material chevronLit;
private final int maxFrameBlocks;
private final int maxIrisBlocks;
private final int minFrameBlocks;
public GateStructureScanner(Set<Material> frameMaterials, Material chevronUnlit, Material chevronLit,
int maxFrameBlocks, int maxIrisBlocks, int minFrameBlocks) {
this.frameMaterials = frameMaterials;
this.chevronUnlit = chevronUnlit;
this.chevronLit = chevronLit;
this.maxFrameBlocks = maxFrameBlocks;
this.maxIrisBlocks = maxIrisBlocks;
this.minFrameBlocks = minFrameBlocks;
}
private boolean isFrameMaterial(Material m) {
return frameMaterials.contains(m) || m == chevronUnlit || m == chevronLit;
}
private boolean isChevronMaterial(Material m) {
return m == chevronUnlit || m == chevronLit;
}
/**
* Scans outward from the given seed block (the block the sign is attached to).
* Returns null if no valid enclosed structure is found.
*/
public GateStructure scan(Block seed) {
if (!isFrameMaterial(seed.getType())) {
// seed itself may be the wall block behind a sign that's part of a bigger build;
// try its direct neighbors for the actual frame block.
for (BlockFace face : NEIGHBORS) {
Block b = seed.getRelative(face);
if (isFrameMaterial(b.getType())) {
seed = b;
break;
}
}
}
if (!isFrameMaterial(seed.getType())) return null;
Set<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>();
List<Block> chevronBlocks = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
queue.add(seed);
frameKeys.add(key(seed));
while (!queue.isEmpty()) {
Block cur = queue.poll();
frameBlocks.add(cur);
if (isChevronMaterial(cur.getType())) chevronBlocks.add(cur);
if (frameBlocks.size() > maxFrameBlocks) return null;
for (BlockFace face : NEIGHBORS) {
Block next = cur.getRelative(face);
long k = key(next);
if (frameKeys.contains(k)) continue;
if (isFrameMaterial(next.getType())) {
frameKeys.add(k);
queue.add(next);
}
}
}
if (frameBlocks.size() < minFrameBlocks) return null;
// Find an interior seed: a non-frame block adjacent to a frame block, that is not
// solid (roughly the middle of the ring). We try several candidates and flood-fill
// each; the first one that stays enclosed within maxIrisBlocks wins.
Set<Long> triedSeeds = new HashSet<>();
for (Block frameBlock : frameBlocks) {
for (BlockFace face : NEIGHBORS) {
Block candidate = frameBlock.getRelative(face);
long ck = key(candidate);
if (frameKeys.contains(ck) || triedSeeds.contains(ck)) continue;
triedSeeds.add(ck);
if (candidate.getType().isSolid()) continue;
List<Block> iris = floodInterior(candidate, frameKeys);
if (iris != null && !iris.isEmpty()) {
return new GateStructure(frameBlocks, chevronBlocks, iris);
}
}
}
return null;
}
/** Flood-fills non-frame blocks starting at seed; fails (returns null) if it escapes the frame boundary. */
private List<Block> floodInterior(Block seed, Set<Long> frameKeys) {
Set<Long> visited = new HashSet<>();
List<Block> interior = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
queue.add(seed);
visited.add(key(seed));
while (!queue.isEmpty()) {
Block cur = queue.poll();
interior.add(cur);
if (interior.size() > maxIrisBlocks) return null;
for (BlockFace face : NEIGHBORS) {
Block next = cur.getRelative(face);
long k = key(next);
if (visited.contains(k) || frameKeys.contains(k)) continue;
if (next.getType().isSolid()) return null; // hit solid, non-frame block: not a clean ring
visited.add(k);
queue.add(next);
}
}
return interior;
}
private long key(Block b) {
return (((long) b.getX() & 0x3FFFFFF) << 38) | (((long) (b.getY() + 512) & 0xFFF) << 26) | ((long) b.getZ() & 0x3FFFFFF);
}
}
@@ -0,0 +1,35 @@
package dev.skywalker3200.stargate.paper.gate;
import dev.skywalker3200.stargate.common.model.Gate;
import org.bukkit.scheduler.BukkitTask;
/** Runtime state for a gate on this server: the persisted model, its scanned blocks, and dial state. */
public class RuntimeGate {
private final Gate gate;
private GateStructure structure; // null if structure could not be (re)scanned
private boolean open = false;
private RuntimeGate connectedTo = null;
private BukkitTask closeTask;
private BukkitTask dialTask;
private int cycleIndex = 0;
public RuntimeGate(Gate gate, GateStructure structure) {
this.gate = gate;
this.structure = structure;
}
public Gate getGate() { return gate; }
public GateStructure getStructure() { return structure; }
public void setStructure(GateStructure structure) { this.structure = structure; }
public boolean isOpen() { return open; }
public void setOpen(boolean open) { this.open = open; }
public RuntimeGate getConnectedTo() { return connectedTo; }
public void setConnectedTo(RuntimeGate connectedTo) { this.connectedTo = connectedTo; }
public BukkitTask getCloseTask() { return closeTask; }
public void setCloseTask(BukkitTask closeTask) { this.closeTask = closeTask; }
public BukkitTask getDialTask() { return dialTask; }
public void setDialTask(BukkitTask dialTask) { this.dialTask = dialTask; }
public int getCycleIndex() { return cycleIndex; }
public void setCycleIndex(int cycleIndex) { this.cycleIndex = cycleIndex; }
}
@@ -0,0 +1,66 @@
package dev.skywalker3200.stargate.paper.listener;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import dev.skywalker3200.stargate.paper.network.CrossServerBridge;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import java.util.List;
/** Walking into an open gate's water plane teleports the player to the connected gate. */
public class GateTeleportListener implements Listener {
private final StargatePlugin plugin;
private final GateManager gateManager;
private final CrossServerBridge crossServerBridge;
public GateTeleportListener(StargatePlugin plugin, GateManager gateManager, CrossServerBridge crossServerBridge) {
this.plugin = plugin;
this.gateManager = gateManager;
this.crossServerBridge = crossServerBridge;
}
@EventHandler(ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
Location to = event.getTo();
if (to == null) return;
if (event.getFrom().getBlockX() == to.getBlockX() && event.getFrom().getBlockY() == to.getBlockY()
&& event.getFrom().getBlockZ() == to.getBlockZ()) return;
Block standing = to.getBlock();
List<RuntimeGate> gates = gateManager.all();
for (RuntimeGate rg : gates) {
if (!rg.isOpen() || rg.getStructure() == null || rg.getConnectedTo() == null) continue;
for (Block iris : rg.getStructure().getIris()) {
if (iris.getX() == standing.getX() && iris.getY() == standing.getY() && iris.getZ() == standing.getZ()
&& iris.getWorld().equals(standing.getWorld())) {
teleport(event.getPlayer(), rg);
return;
}
}
}
}
private void teleport(org.bukkit.entity.Player player, RuntimeGate entered) {
RuntimeGate dest = entered.getConnectedTo();
if (dest == null) return;
boolean remote = !dest.getGate().getServerId().equals(plugin.getServerId());
if (remote) {
crossServerBridge.sendPlayerThroughGate(player, dest.getGate());
return;
}
var world = org.bukkit.Bukkit.getWorld(dest.getGate().getWorld());
if (world == null) return;
Location exit = new Location(world, dest.getGate().getExitX() + 0.5, dest.getGate().getExitY(),
dest.getGate().getExitZ() + 0.5, dest.getGate().getExitYaw(), 0f);
player.teleport(exit);
player.playSound(exit, org.bukkit.Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1f);
}
}
@@ -0,0 +1,73 @@
package dev.skywalker3200.stargate.paper.listener;
import dev.skywalker3200.stargate.common.model.Gate;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import dev.skywalker3200.stargate.paper.util.SignRenderer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.SignChangeEvent;
import java.util.EnumSet;
import java.util.UUID;
/** Handles turning a freshly-placed sign reading "[Stargate]" into a registered gate. */
public class SignCreateListener implements Listener {
private static final String HEADER = "[stargate]";
private final StargatePlugin plugin;
private final GateManager gateManager;
public SignCreateListener(StargatePlugin plugin, GateManager gateManager) {
this.plugin = plugin;
this.gateManager = gateManager;
}
@EventHandler(ignoreCancelled = true)
public void onSignChange(SignChangeEvent event) {
String line0 = stripped(event.line(0));
if (line0 == null || !line0.equalsIgnoreCase(HEADER)) return;
var player = event.getPlayer();
if (!player.hasPermission("stargate.create")) {
player.sendMessage(Component.text("You don't have permission to create stargates.", NamedTextColor.RED));
resetLine(event);
return;
}
String network = stripped(event.line(1));
if (network == null || network.isBlank()) network = gateManager.getConfig().defaultNetwork;
String name = stripped(event.line(2));
if (name == null || name.isBlank()) name = "Gate-" + Integer.toHexString((int) (Math.random() * 0xFFFF));
EnumSet<Gate.Flag> flags = EnumSet.of(Gate.Flag.PUBLIC);
String flagLine = stripped(event.line(3));
if (flagLine != null) {
if (flagLine.equalsIgnoreCase("hidden")) { flags.clear(); flags.add(Gate.Flag.HIDDEN); }
}
RuntimeGate rg = gateManager.createGate(event.getBlock(), network, name, player.getUniqueId(), flags);
if (rg == null) {
player.sendMessage(Component.text("No valid gate structure found. Build the frame first, then place the sign.", NamedTextColor.RED));
resetLine(event);
return;
}
player.sendMessage(Component.text("Stargate '" + name + "' created on network '" + network + "'.", NamedTextColor.AQUA));
SignRenderer.render(event, rg, gateManager);
}
private String stripped(Component c) {
if (c == null) return null;
return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(c);
}
private void resetLine(SignChangeEvent event) {
event.line(0, Component.text(""));
}
}
@@ -0,0 +1,100 @@
package dev.skywalker3200.stargate.paper.listener;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import dev.skywalker3200.stargate.paper.network.CrossServerBridge;
import dev.skywalker3200.stargate.paper.util.SignRenderer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.Sign;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import java.util.List;
/** Right-click a gate's sign to cycle its destination, left-click to dial it. */
public class SignInteractListener implements Listener {
private final StargatePlugin plugin;
private final GateManager gateManager;
private final CrossServerBridge crossServerBridge;
public SignInteractListener(StargatePlugin plugin, GateManager gateManager, CrossServerBridge crossServerBridge) {
this.plugin = plugin;
this.gateManager = gateManager;
this.crossServerBridge = crossServerBridge;
}
@EventHandler(ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
Block block = event.getClickedBlock();
if (block == null || !(block.getState() instanceof Sign)) return;
RuntimeGate rg = gateManager.getBySign(block);
if (rg == null) return;
var player = event.getPlayer();
if (!player.hasPermission("stargate.use")) return;
event.setCancelled(true);
if (event.getAction() == Action.RIGHT_CLICK_BLOCK) {
List<RuntimeGate> destinations = gateManager.destinationsFor(rg);
if (destinations.isEmpty()) {
player.sendMessage(Component.text("No other gates on network '" + rg.getGate().getNetwork() + "'.", NamedTextColor.RED));
return;
}
if (rg.getGate().isFixed()) {
player.sendMessage(Component.text("This gate's destination is fixed.", NamedTextColor.RED));
return;
}
rg.setCycleIndex(rg.getCycleIndex() + 1);
SignRenderer.render(rg, gateManager);
player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1f, 1f);
} else if (event.getAction() == Action.LEFT_CLICK_BLOCK) {
dial(rg, player);
}
}
private void dial(RuntimeGate from, org.bukkit.entity.Player player) {
if (from.isOpen()) {
player.sendMessage(Component.text("This gate is already active.", NamedTextColor.RED));
return;
}
List<RuntimeGate> destinations = gateManager.destinationsFor(from);
RuntimeGate to;
if (from.getGate().isFixed()) {
to = destinations.stream()
.filter(g -> g.getGate().getName().equalsIgnoreCase(from.getGate().getFixedDestination()))
.findFirst().orElse(null);
} else {
if (destinations.isEmpty()) {
player.sendMessage(Component.text("No destinations available.", NamedTextColor.RED));
return;
}
int idx = ((from.getCycleIndex() % destinations.size()) + destinations.size()) % destinations.size();
to = destinations.get(idx);
}
if (to == null) {
player.sendMessage(Component.text("Destination gate not found.", NamedTextColor.RED));
return;
}
if (to.isOpen()) {
player.sendMessage(Component.text("Destination gate is busy.", NamedTextColor.RED));
return;
}
boolean remote = !to.getGate().getServerId().equals(plugin.getServerId());
if (remote && !crossServerBridge.isEnabled()) {
player.sendMessage(Component.text("Cross-server dialing is disabled on this server.", NamedTextColor.RED));
return;
}
player.sendMessage(Component.text("Dialing " + to.getGate().getName() + "...", NamedTextColor.AQUA));
gateManager.dial(from, to, player);
SignRenderer.render(from, gateManager);
}
}
@@ -0,0 +1,62 @@
package dev.skywalker3200.stargate.paper.listener;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.block.Block;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import java.util.List;
import java.util.UUID;
/** Breaking a gate's sign, frame, or chevron blocks destroys its registration (owner/admin only). */
public class StructureProtectListener implements Listener {
private final GateManager gateManager;
public StructureProtectListener(GateManager gateManager) {
this.gateManager = gateManager;
}
@EventHandler(ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
Block block = event.getBlock();
RuntimeGate rg = gateManager.getBySign(block);
boolean isSign = rg != null;
if (!isSign) {
rg = findByFrameBlock(block);
}
if (rg == null) return;
UUID owner = rg.getGate().getOwner();
var player = event.getPlayer();
boolean allowed = player.hasPermission("stargate.admin")
|| (owner != null && owner.equals(player.getUniqueId()) && player.hasPermission("stargate.destroy"));
if (!allowed) {
event.setCancelled(true);
player.sendMessage(Component.text("You can't break this stargate.", NamedTextColor.RED));
return;
}
gateManager.destroyGate(rg);
player.sendMessage(Component.text("Stargate '" + rg.getGate().getName() + "' destroyed.", NamedTextColor.YELLOW));
}
private RuntimeGate findByFrameBlock(Block block) {
for (RuntimeGate rg : gateManager.all()) {
if (rg.getStructure() == null) continue;
List<Block> frame = rg.getStructure().getFrame();
for (Block b : frame) {
if (b.getX() == block.getX() && b.getY() == block.getY() && b.getZ() == block.getZ()
&& b.getWorld().equals(block.getWorld())) {
return rg;
}
}
}
return null;
}
}
@@ -0,0 +1,87 @@
package dev.skywalker3200.stargate.paper.network;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import dev.skywalker3200.stargate.common.model.Gate;
import dev.skywalker3200.stargate.common.network.StargateChannel;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.util.UUID;
/**
* Talks to the stargate-velocity / stargate-bungee companion plugin over plugin messaging so a
* player dialing a gate hosted on another backend server actually gets moved there and then
* warped to the right spot once they land.
*/
public class CrossServerBridge implements PluginMessageListener {
private final StargatePlugin plugin;
private final GateManager gateManager;
private boolean enabled = false;
public CrossServerBridge(StargatePlugin plugin, GateManager gateManager) {
this.plugin = plugin;
this.gateManager = gateManager;
}
public void register() {
this.enabled = true;
Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, StargateChannel.CHANNEL);
Bukkit.getMessenger().registerIncomingPluginChannel(plugin, StargateChannel.CHANNEL, this);
plugin.getLogger().info("[Stargate] Cross-server bridge registered on channel " + StargateChannel.CHANNEL);
}
public boolean isEnabled() {
return enabled;
}
/** Asks the proxy to move this player to the backend server hosting {@code destGate}. */
public void sendPlayerThroughGate(Player player, Gate destGate) {
if (!enabled) return;
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeByte(StargateChannel.OP_TELEPORT_REQUEST);
out.writeUTF(player.getUniqueId().toString());
out.writeUTF(destGate.getServerId());
out.writeUTF(destGate.getId().toString());
player.sendPluginMessage(plugin, StargateChannel.CHANNEL, out.toByteArray());
}
@Override
public void onPluginMessageReceived(String channel, Player receivingPlayer, byte[] message) {
if (!channel.equals(StargateChannel.CHANNEL)) return;
try {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(message));
byte op = in.readByte();
if (op != StargateChannel.OP_TELEPORT_DELIVER) return;
UUID playerId = UUID.fromString(in.readUTF());
UUID gateId = UUID.fromString(in.readUTF());
var rg = gateManager.getById(gateId);
if (rg == null) {
plugin.getLogger().warning("[Stargate] Received teleport-deliver for unknown gate " + gateId);
return;
}
Gate gate = rg.getGate();
World world = Bukkit.getWorld(gate.getWorld());
if (world == null) return;
Bukkit.getScheduler().runTask(plugin, () -> {
Player p = Bukkit.getPlayer(playerId);
if (p == null) return;
Location exit = new Location(world, gate.getExitX() + 0.5, gate.getExitY(), gate.getExitZ() + 0.5, gate.getExitYaw(), 0f);
p.teleport(exit);
});
} catch (Exception e) {
plugin.getLogger().warning("[Stargate] Failed to handle cross-server teleport message: " + e.getMessage());
}
}
}
@@ -0,0 +1,46 @@
package dev.skywalker3200.stargate.paper.util;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.block.Block;
import org.bukkit.block.Sign;
import org.bukkit.block.sign.Side;
import org.bukkit.event.block.SignChangeEvent;
import java.util.List;
/** Renders a gate's current state (name, network, selected destination) onto its control sign. */
public final class SignRenderer {
private SignRenderer() {}
public static void render(SignChangeEvent event, RuntimeGate rg, GateManager gateManager) {
event.line(0, Component.text(rg.getGate().getName(), NamedTextColor.DARK_AQUA));
event.line(1, Component.text(rg.getGate().getNetwork(), NamedTextColor.GRAY));
event.line(2, destinationLine(rg, gateManager));
event.line(3, rg.isOpen() ? Component.text("[connected]", NamedTextColor.GREEN) : Component.text(""));
}
public static void render(RuntimeGate rg, GateManager gateManager) {
Block b = org.bukkit.Bukkit.getWorld(rg.getGate().getSignWorld())
.getBlockAt(rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ());
if (!(b.getState() instanceof Sign sign)) return;
sign.getSide(Side.FRONT).line(0, Component.text(rg.getGate().getName(), NamedTextColor.DARK_AQUA));
sign.getSide(Side.FRONT).line(1, Component.text(rg.getGate().getNetwork(), NamedTextColor.GRAY));
sign.getSide(Side.FRONT).line(2, destinationLine(rg, gateManager));
sign.getSide(Side.FRONT).line(3, rg.isOpen() ? Component.text("[connected]", NamedTextColor.GREEN) : Component.text(""));
sign.update(true, false);
}
private static Component destinationLine(RuntimeGate rg, GateManager gateManager) {
List<RuntimeGate> destinations = gateManager.destinationsFor(rg);
if (destinations.isEmpty()) {
return Component.text("no destinations", NamedTextColor.DARK_GRAY);
}
int idx = ((rg.getCycleIndex() % destinations.size()) + destinations.size()) % destinations.size();
RuntimeGate dest = destinations.get(idx);
return Component.text("> " + dest.getGate().getName() + " <", NamedTextColor.GOLD);
}
}
@@ -0,0 +1,55 @@
# Stargate configuration
# Unique id for THIS server. Used to tag gates created here and to route cross-server
# dials through the proxy. Must be unique across your network and match the server's
# name as configured in the Velocity/Bungee proxy config.
server-id: "server1"
storage:
# sqlite -> single server, file-based, zero setup
# mysql -> required if you want gates to be visible/dialable across multiple
# backend servers sharing this same database
type: sqlite
mysql:
host: localhost
port: 3306
database: stargate
username: stargate
password: ""
table-prefix: "stargate_"
# Enables sending players to another backend server when they dial a gate whose
# server-id differs from this one. Requires the stargate-velocity or stargate-bungee
# companion plugin installed on the proxy, and storage.type: mysql so all servers
# share gate data.
cross-server:
enabled: false
gate:
# Any of these materials count as the decorative frame ring. Mix and match to match
# your build (the default matches an obsidian/gold-block/birch-planks arch).
frame-materials:
- OBSIDIAN
- GOLD_BLOCK
- BIRCH_PLANKS
- OAK_PLANKS
# Chevron blocks embedded in the frame. Build them as chevron-unlit-material; the
# plugin swaps them to chevron-lit-material as the gate dials/opens, and swaps them
# back when the gate closes/deactivates.
chevron-unlit-material: BLACK_STAINED_GLASS
chevron-lit-material: GLOWSTONE
# Material poured into the interior (the "event horizon") while the gate is open.
iris-material: WATER
max-frame-blocks: 300
max-iris-blocks: 200
min-frame-blocks: 8
dialing:
# Seconds the gate stays open (iris filled) before auto-closing if nobody walks through.
open-seconds: 10
# Delay in ticks between lighting each chevron during the dial animation.
chevron-tick-delay: 4
play-sounds: true
network:
default-network: "main"
@@ -0,0 +1,27 @@
name: Stargate
version: '${version}'
main: dev.skywalker3200.stargate.paper.StargatePlugin
api-version: '1.21'
author: skywalker3200
description: Network-aware Stargate portals with cross-server dialing.
folia-supported: false
commands:
stargate:
description: Manage Stargate networks and gates.
aliases: [sg]
usage: /sg <create|destroy|network|reload|list> ...
permissions:
stargate.use:
description: Allows dialing and using stargates.
default: true
stargate.create:
description: Allows creating new stargates.
default: op
stargate.destroy:
description: Allows destroying stargates you do not own.
default: op
stargate.admin:
description: Allows reload, network admin, and bypassing ownership checks.
default: op