Fix one-way travel, rework iris to SG-1 mechanics, glowstone chevrons, pin-locked shield

One-way travel bug: the exit point landed players directly inside the
destination gate's own iris footprint. Since dialing opens both ends,
arriving there immediately re-triggered the teleport listener and
bounced them straight back - reading exactly like one-way travel.
GateManager.computeSafeLanding now nudges the landing spot along the
player's travel direction until it clears the destination's iris
blocks, and GateTeleportListener adds a short per-player cooldown as a
second guard against re-trigger. Same-server teleports now also
preserve the player's exact yaw/pitch and velocity instead of forcing
a fixed stored orientation, so entering forward always means exiting
forward; the cross-server plugin-messaging protocol was extended to
carry yaw/pitch through the proxy hop for the same reason.

Iris rework to match SG-1 rather than a binary open/closed gate:
- Idle (not connected): iris-idle-material, default AIR - you see
  straight through an inactive ring, not a wall.
- Connected: iris-open-material (WATER, the event horizon) unless the
  gate's separate iris shield is closed, in which case
  iris-shield-material (IRON_BLOCK) blocks travel even though the
  wormhole is active.
- A button placed directly below the control sign toggles the shield
  (GateIrisButtonListener). Closing needs nothing; opening a gate with
  a pin code set (/sg pin <code>) prompts the player to type it in
  chat within gate.pin-timeout-seconds first.

Also: gate.chevron-lit-material default changed from gilded blackstone
to glowstone per feedback.
This commit is contained in:
Michael Burgess
2026-08-09 11:36:17 -04:00
parent 190f875d23
commit b822389387
13 changed files with 387 additions and 70 deletions
+25 -9
View File
@@ -34,19 +34,22 @@ further outward than their immediate ring-neighbours - the "elbow" points of the
shape, which is where a real Stargate's chevrons sit (an 11-wide octagonal ring like
the reference design naturally produces 7 of them: the top apex, the four shoulder
elbows, and the two side bumps). Those blocks swap to `gate.chevron-lit-material`
(default gilded blackstone) while dialing/open, and revert to whatever they looked
like at rest when the gate closes - so a chevron can rest as plain obsidian and blend
(default glowstone) while dialing/open, and revert to whatever they looked like at
rest when the gate closes - so a chevron can rest as plain obsidian and blend
invisibly into the frame until it lights up.
1. Build the ring.
2. Place a sign anywhere - it doesn't need to touch the frame, so it can act like a DHD
2. Optionally place a button directly below where you'll put the sign - this becomes
the iris shield toggle (see below). Not required; a gate without one just has no
shield control.
3. Place a sign anywhere - it doesn't need to touch the frame, so it can act like a DHD
console standing apart from the gate - with:
- Line 1: `[Stargate]`
- Line 2: network name (blank = default network)
- Line 3: gate name (blank = auto-generated)
- Line 4: `hidden` to keep it out of the cycle list, `fixed:GateName` to lock this
gate to always dial `GateName` (no right-click cycling), or blank
3. The sign will show "Punch the gate (30s)". Within `gate.link-timeout-seconds`,
4. The sign will show "Punch the gate (30s)". Within `gate.link-timeout-seconds`,
left-click (punch) any frame block, within `gate.max-link-distance` of the sign.
The plugin scans out from that block and links the sign to it.
@@ -57,11 +60,21 @@ material, unsealed interior, too far from the sign, etc.
- **Right-click** the sign: cycles the destination shown on line 3 among the other
gates on the same network.
- **Left-click** the sign: dials the shown destination chevrons light in sequence,
then the interior switches from `gate.iris-closed-material` (default bedrock - solid,
nothing can pass through a closed/idle gate) to `gate.iris-open-material` (default
water, the "event horizon"). Walk into it to teleport. It auto-closes after
`dialing.open-seconds`, switching the interior back to bedrock.
- **Left-click** the sign: dials the shown destination - chevrons light in sequence,
then the interior switches from `gate.iris-idle-material` (default air - an idle gate
is just an empty ring you can see straight through) to either the event horizon
(`gate.iris-open-material`, default water) or, if the iris shield is closed, to
`gate.iris-shield-material` (default iron block) instead, blocking travel even
though the gate is actively connected. It auto-closes after `dialing.open-seconds`,
switching back to the idle material either way.
- Walking into the event horizon teleports you to the connected gate, arriving just
clear of its iris (never inside it - that would immediately teleport you right back)
and still facing whatever direction you were already walking, so entering forward
always means exiting forward.
- **Right-click a button placed directly below the sign** toggles the iris shield.
Closing it never needs anything extra. If the gate has a pin set (`/sg pin <code>`),
opening a closed shield prompts you to type the code in chat within
`gate.pin-timeout-seconds` before it'll open.
## Multi-world
@@ -93,5 +106,8 @@ server's Stargate instance teleports them to the gate's exit point.
## Commands
- `/sg list [network]`
- `/sg networks`
- `/sg destroy` (look at a gate's sign)
- `/sg pin <code|clear>` (look at a gate's sign) — requires that code in chat before a
closed iris shield will open on that gate
- `/sg reload`
@@ -22,7 +22,9 @@ import java.util.concurrent.ConcurrentHashMap;
/** BungeeCord counterpart of the Velocity bridge: same wire protocol, same job. */
public class StargateBungeePlugin extends Plugin implements Listener {
private final Map<UUID, String> pendingGateByPlayer = new ConcurrentHashMap<>();
private record PendingWarp(String gateId, float yaw, float pitch) {}
private final Map<UUID, PendingWarp> pendingGateByPlayer = new ConcurrentHashMap<>();
@Override
public void onEnable() {
@@ -44,6 +46,8 @@ public class StargateBungeePlugin extends Plugin implements Listener {
UUID playerId = UUID.fromString(in.readUTF());
String targetServer = in.readUTF();
String gateId = in.readUTF();
float yaw = in.readFloat();
float pitch = in.readFloat();
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(playerId);
ServerInfo target = ProxyServer.getInstance().getServerInfo(targetServer);
@@ -52,7 +56,7 @@ public class StargateBungeePlugin extends Plugin implements Listener {
return;
}
pendingGateByPlayer.put(playerId, gateId);
pendingGateByPlayer.put(playerId, new PendingWarp(gateId, yaw, pitch));
player.connect(target);
} catch (Exception e) {
getLogger().warning("Failed to process stargate plugin message: " + e.getMessage());
@@ -62,8 +66,8 @@ public class StargateBungeePlugin extends Plugin implements Listener {
@EventHandler
public void onServerConnected(ServerConnectedEvent event) {
ProxiedPlayer player = event.getPlayer();
String gateId = pendingGateByPlayer.remove(player.getUniqueId());
if (gateId == null) return;
PendingWarp warp = pendingGateByPlayer.remove(player.getUniqueId());
if (warp == null) return;
Server server = event.getServer();
try {
@@ -71,7 +75,9 @@ public class StargateBungeePlugin extends Plugin implements Listener {
DataOutputStream out = new DataOutputStream(bytes);
out.writeByte(StargateChannel.OP_TELEPORT_DELIVER);
out.writeUTF(player.getUniqueId().toString());
out.writeUTF(gateId);
out.writeUTF(warp.gateId());
out.writeFloat(warp.yaw());
out.writeFloat(warp.pitch());
server.sendData(StargateChannel.CHANNEL, bytes.toByteArray());
} catch (Exception e) {
getLogger().warning("Failed to deliver stargate teleport: " + e.getMessage());
@@ -39,6 +39,12 @@ public class Gate {
private int linkX;
private int linkY;
private int linkZ;
// Iris shield: a separate safety barrier from the wormhole connection itself. While the
// gate is connected, the iris being closed blocks travel and shows a solid barrier instead
// of the event horizon. Defaults open so a freshly linked gate works immediately.
private boolean irisClosed = false;
// If set, opening a closed iris via the button requires typing this in chat first.
private String pinCode;
public Gate(UUID id, String name, String network, String serverId, String world,
int exitX, int exitY, int exitZ, float exitYaw,
@@ -97,6 +103,10 @@ public class Gate {
public int getLinkX() { return linkX; }
public int getLinkY() { return linkY; }
public int getLinkZ() { return linkZ; }
public boolean isIrisClosed() { return irisClosed; }
public void setIrisClosed(boolean irisClosed) { this.irisClosed = irisClosed; }
public String getPinCode() { return pinCode; }
public void setPinCode(String pinCode) { this.pinCode = pinCode; }
/** Fully-qualified identity used for cross-server lookups: server/network/name */
public String qualifiedName() {
@@ -78,18 +78,22 @@ public class SqlGateStorage implements GateStorage {
"fixed_destination VARCHAR(64)," +
"link_x INTEGER NOT NULL DEFAULT 0," +
"link_y INTEGER NOT NULL DEFAULT 0," +
"link_z INTEGER NOT NULL DEFAULT 0" +
"link_z INTEGER NOT NULL DEFAULT 0," +
"iris_closed INTEGER NOT NULL DEFAULT 0," +
"pin_code VARCHAR(32)" +
")");
s.executeUpdate("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "network ON " + tablePrefix + "gates(network)");
migrateColumn(s, "link_x");
migrateColumn(s, "link_y");
migrateColumn(s, "link_z");
migrateIntColumn(s, "link_x");
migrateIntColumn(s, "link_y");
migrateIntColumn(s, "link_z");
migrateIntColumn(s, "iris_closed");
migrateStringColumn(s, "pin_code", "VARCHAR(32)");
}
logger.info("[Stargate] Storage initialised (" + driver + ")");
}
/** Best-effort ALTER TABLE for databases created before link_x/y/z existed; ignored if the column is already there. */
private void migrateColumn(Statement s, String column) {
/** Best-effort ALTER TABLE for databases created before this column existed; ignored if it's already there. */
private void migrateIntColumn(Statement s, String column) {
try {
s.executeUpdate("ALTER TABLE " + tablePrefix + "gates ADD COLUMN " + column + " INTEGER NOT NULL DEFAULT 0");
} catch (Exception ignored) {
@@ -97,6 +101,14 @@ public class SqlGateStorage implements GateStorage {
}
}
private void migrateStringColumn(Statement s, String column, String type) {
try {
s.executeUpdate("ALTER TABLE " + tablePrefix + "gates ADD COLUMN " + column + " " + type);
} catch (Exception ignored) {
// column already exists
}
}
@Override
public void close() {
if (dataSource != null) dataSource.close();
@@ -105,17 +117,18 @@ public class SqlGateStorage implements GateStorage {
@Override
public void saveGate(Gate gate) {
String sql = "REPLACE INTO " + tablePrefix + "gates " +
"(id,name,network,server_id,world,exit_x,exit_y,exit_z,exit_yaw,sign_x,sign_y,sign_z,sign_world,facing,owner,flags,fixed_destination,link_x,link_y,link_z) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
"(id,name,network,server_id,world,exit_x,exit_y,exit_z,exit_yaw,sign_x,sign_y,sign_z,sign_world,facing,owner,flags,fixed_destination,link_x,link_y,link_z,iris_closed,pin_code) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
if (driver == Driver.MYSQL) {
sql = "INSERT INTO " + tablePrefix + "gates " +
"(id,name,network,server_id,world,exit_x,exit_y,exit_z,exit_yaw,sign_x,sign_y,sign_z,sign_world,facing,owner,flags,fixed_destination,link_x,link_y,link_z) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " +
"(id,name,network,server_id,world,exit_x,exit_y,exit_z,exit_yaw,sign_x,sign_y,sign_z,sign_world,facing,owner,flags,fixed_destination,link_x,link_y,link_z,iris_closed,pin_code) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " +
"name=VALUES(name),network=VALUES(network),server_id=VALUES(server_id),world=VALUES(world)," +
"exit_x=VALUES(exit_x),exit_y=VALUES(exit_y),exit_z=VALUES(exit_z),exit_yaw=VALUES(exit_yaw)," +
"sign_x=VALUES(sign_x),sign_y=VALUES(sign_y),sign_z=VALUES(sign_z),sign_world=VALUES(sign_world)," +
"facing=VALUES(facing),owner=VALUES(owner),flags=VALUES(flags),fixed_destination=VALUES(fixed_destination)," +
"link_x=VALUES(link_x),link_y=VALUES(link_y),link_z=VALUES(link_z)";
"link_x=VALUES(link_x),link_y=VALUES(link_y),link_z=VALUES(link_z)," +
"iris_closed=VALUES(iris_closed),pin_code=VALUES(pin_code)";
}
try (Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, gate.getId().toString());
@@ -138,6 +151,8 @@ public class SqlGateStorage implements GateStorage {
ps.setInt(18, gate.getLinkX());
ps.setInt(19, gate.getLinkY());
ps.setInt(20, gate.getLinkZ());
ps.setInt(21, gate.isIrisClosed() ? 1 : 0);
ps.setString(22, gate.getPinCode());
ps.executeUpdate();
} catch (Exception e) {
logger.severe("[Stargate] Failed to save gate " + gate.getName() + ": " + e.getMessage());
@@ -190,7 +205,7 @@ public class SqlGateStorage implements GateStorage {
private Gate fromRow(ResultSet rs) throws Exception {
String ownerStr = rs.getString("owner");
String fixedDest = rs.getString("fixed_destination");
return new Gate(
Gate gate = new Gate(
UUID.fromString(rs.getString("id")),
rs.getString("name"),
rs.getString("network"),
@@ -204,6 +219,9 @@ public class SqlGateStorage implements GateStorage {
fixedDest,
rs.getInt("link_x"), rs.getInt("link_y"), rs.getInt("link_z")
);
gate.setIrisClosed(rs.getInt("iris_closed") != 0);
gate.setPinCode(rs.getString("pin_code"));
return gate;
}
private String serializeFlags(Set<Gate.Flag> flags) {
@@ -48,6 +48,7 @@ public class StargatePlugin extends JavaPlugin {
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);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateIrisButtonListener(this, gateManager), this);
StargateCommand command = new StargateCommand(this, gateManager);
getCommand("stargate").setExecutor(command);
@@ -30,7 +30,7 @@ public class StargateCommand implements CommandExecutor, TabCompleter {
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
sender.sendMessage(Component.text("Usage: /sg <list|networks|destroy|reload>", NamedTextColor.YELLOW));
sender.sendMessage(Component.text("Usage: /sg <list|networks|destroy|pin|reload>", NamedTextColor.YELLOW));
return true;
}
@@ -81,6 +81,35 @@ public class StargateCommand implements CommandExecutor, TabCompleter {
gateManager.destroyGate(rg);
player.sendMessage(Component.text("Destroyed '" + rg.getGate().getName() + "'.", NamedTextColor.YELLOW));
}
case "pin" -> {
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 set its pin.", NamedTextColor.RED));
return true;
}
boolean allowed = player.hasPermission("stargate.admin")
|| (rg.getGate().getOwner() != null && rg.getGate().getOwner().equals(player.getUniqueId()) && player.hasPermission("stargate.create"));
if (!allowed) {
player.sendMessage(Component.text("You can't set this stargate's pin.", NamedTextColor.RED));
return true;
}
if (args.length < 2) {
player.sendMessage(Component.text("Usage: /sg pin <code|clear>", NamedTextColor.YELLOW));
return true;
}
if (args[1].equalsIgnoreCase("clear")) {
gateManager.setPinCode(rg, null);
player.sendMessage(Component.text("Pin cleared - iris opens freely once unshielded.", NamedTextColor.GREEN));
} else {
gateManager.setPinCode(rg, args[1]);
player.sendMessage(Component.text("Pin set. Closing the iris will now require it to reopen.", NamedTextColor.GREEN));
}
}
default -> sender.sendMessage(Component.text("Unknown subcommand.", NamedTextColor.RED));
}
return true;
@@ -89,7 +118,7 @@ public class StargateCommand implements CommandExecutor, TabCompleter {
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
return List.of("list", "networks", "destroy", "reload").stream()
return List.of("list", "networks", "destroy", "pin", "reload").stream()
.filter(s -> s.startsWith(args[0].toLowerCase()))
.collect(Collectors.toList());
}
@@ -12,8 +12,9 @@ public class GateConfig {
public final Set<Material> frameMaterials;
public final Material chevronLit;
public final Material irisIdleMaterial;
public final Material irisOpenMaterial;
public final Material irisClosedMaterial;
public final Material irisShieldMaterial;
public final int maxFrameBlocks;
public final int maxIrisBlocks;
public final int minFrameBlocks;
@@ -23,6 +24,7 @@ public class GateConfig {
public final String defaultNetwork;
public final int linkTimeoutSeconds;
public final int maxLinkDistance;
public final int pinTimeoutSeconds;
public GateConfig(FileConfiguration cfg, Logger logger) {
Set<Material> materials = new HashSet<>();
@@ -34,9 +36,10 @@ public class GateConfig {
if (materials.isEmpty()) materials.add(Material.OBSIDIAN);
this.frameMaterials = materials;
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GILDED_BLACKSTONE, logger);
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GLOWSTONE, logger);
this.irisIdleMaterial = matOr(cfg.getString("gate.iris-idle-material"), Material.AIR, logger);
this.irisOpenMaterial = matOr(cfg.getString("gate.iris-open-material"), Material.WATER, logger);
this.irisClosedMaterial = matOr(cfg.getString("gate.iris-closed-material"), Material.BEDROCK, logger);
this.irisShieldMaterial = matOr(cfg.getString("gate.iris-shield-material"), Material.IRON_BLOCK, 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);
@@ -46,6 +49,7 @@ public class GateConfig {
this.defaultNetwork = cfg.getString("network.default-network", "main");
this.linkTimeoutSeconds = cfg.getInt("gate.link-timeout-seconds", 30);
this.maxLinkDistance = cfg.getInt("gate.max-link-distance", 64);
this.pinTimeoutSeconds = cfg.getInt("gate.pin-timeout-seconds", 20);
}
private Material matOr(String s, Material fallback, Logger logger) {
@@ -5,18 +5,22 @@ import dev.skywalker3200.stargate.common.storage.GateStorage;
import dev.skywalker3200.stargate.paper.StargatePlugin;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Levelled;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -57,10 +61,10 @@ public class GateManager {
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(linked);
if (result.isSuccess()) {
structure = result.structure;
// a restart can't preserve "open" runtime state, so every gate comes
// back closed - make sure the iris actually reflects that
// a restart can't preserve "connected" runtime state, so every gate
// comes back idle - make sure the interior actually reflects that
for (Block b : structure.getIris()) {
b.setType(config.irisClosedMaterial);
b.setType(config.irisIdleMaterial);
}
} else {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName()
@@ -175,7 +179,7 @@ public class GateManager {
}
GateStructure structure = result.structure;
for (Block b : structure.getIris()) {
b.setType(config.irisClosedMaterial);
b.setType(config.irisIdleMaterial);
}
Location exit = computeExitLocation(structure, clickedBlock.getWorld(), facing);
@@ -288,13 +292,7 @@ public class GateManager {
gate.setOpen(true);
gate.setConnectedTo(connectedTo);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(config.irisOpenMaterial);
if (b.getBlockData() instanceof Levelled lvl) {
lvl.setLevel(0);
b.setBlockData(lvl);
}
}
applyIrisMaterial(gate);
for (GateStructure.ChevronSlot c : gate.getStructure().getChevrons()) {
c.getBlock().setType(config.chevronLit);
}
@@ -319,7 +317,7 @@ public class GateManager {
gate.setConnectedTo(null);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(config.irisClosedMaterial);
b.setType(config.irisIdleMaterial);
}
for (GateStructure.ChevronSlot c : gate.getStructure().getChevrons()) {
c.getBlock().setType(c.getRestingMaterial());
@@ -335,4 +333,81 @@ public class GateManager {
if (rg.isOpen()) closeGate(rg);
}
}
/** Sets the iris blocks to whatever the gate's current connected/shield state implies. */
private void applyIrisMaterial(RuntimeGate gate) {
Material mat = gate.getGate().isIrisClosed() ? config.irisShieldMaterial : config.irisOpenMaterial;
for (Block b : gate.getStructure().getIris()) {
b.setType(mat);
if (b.getBlockData() instanceof Levelled lvl) {
lvl.setLevel(0);
b.setBlockData(lvl);
}
}
}
// ---- Iris shield ----
/** Flips the iris shield and, if the gate is currently connected, updates the blocks immediately. Returns the new closed-state. */
public boolean toggleIrisShield(RuntimeGate rg) {
boolean nowClosed = !rg.getGate().isIrisClosed();
rg.getGate().setIrisClosed(nowClosed);
saveGate(rg);
if (rg.isOpen() && rg.getStructure() != null) {
applyIrisMaterial(rg);
}
return nowClosed;
}
/** Opens the shield directly, bypassing the toggle - used once a correct pin has been entered. */
public void openIrisShield(RuntimeGate rg) {
rg.getGate().setIrisClosed(false);
saveGate(rg);
if (rg.isOpen() && rg.getStructure() != null) {
applyIrisMaterial(rg);
}
}
public void setPinCode(RuntimeGate rg, String pin) {
rg.getGate().setPinCode(pin);
saveGate(rg);
}
// ---- Landing spot: never inside the destination's own iris footprint ----
/**
* Picks a safe arrival spot at {@code dest}: the gate's exit point, nudged along
* {@code travelDirection} (horizontal component only) until it's clear of the destination's
* iris blocks. Without this, arriving players land back inside the iris and immediately
* trigger another teleport, bouncing them straight back where they came from.
*/
public Location computeSafeLanding(RuntimeGate dest, Vector travelDirection, float yaw, float pitch) {
World world = Bukkit.getWorld(dest.getGate().getWorld());
if (world == null) return null;
double x = dest.getGate().getExitX() + 0.5;
double y = dest.getGate().getExitY();
double z = dest.getGate().getExitZ() + 0.5;
Set<Long> irisXZ = new HashSet<>();
if (dest.getStructure() != null) {
for (Block b : dest.getStructure().getIris()) {
irisXZ.add((((long) b.getX()) << 32) ^ (b.getZ() & 0xFFFFFFFFL));
}
}
Vector dir = travelDirection.clone();
dir.setY(0);
if (dir.lengthSquared() < 0.0001) dir = new Vector(0, 0, 1);
dir.normalize();
for (int i = 0; i < 8 && !irisXZ.isEmpty(); i++) {
long key = ((long) Math.floor(x) << 32) ^ ((long) Math.floor(z) & 0xFFFFFFFFL);
if (!irisXZ.contains(key)) break;
x += dir.getX() * 0.5;
z += dir.getZ() * 0.5;
}
return new Location(world, x, y, z, yaw, pitch);
}
}
@@ -0,0 +1,122 @@
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 io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.scheduler.BukkitTask;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* A button placed directly below a gate's control sign toggles its iris shield. Closing it
* requires nothing; opening a shield that has a pin code set prompts the player to type it in
* chat first, matching an iris/DHD access-code mechanic.
*/
public class GateIrisButtonListener implements Listener {
private final StargatePlugin plugin;
private final GateManager gateManager;
private final Map<UUID, PendingPin> pendingPins = new HashMap<>();
private static final class PendingPin {
final RuntimeGate gate;
BukkitTask timeoutTask;
PendingPin(RuntimeGate gate) { this.gate = gate; }
}
public GateIrisButtonListener(StargatePlugin plugin, GateManager gateManager) {
this.plugin = plugin;
this.gateManager = gateManager;
}
@EventHandler(ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
Block block = event.getClickedBlock();
if (block == null || !block.getType().name().endsWith("_BUTTON")) return;
Block signSpot = block.getRelative(BlockFace.UP);
RuntimeGate rg = gateManager.getBySign(signSpot);
if (rg == null) return;
Player player = event.getPlayer();
if (!player.hasPermission("stargate.use")) return;
event.setCancelled(true);
String pin = rg.getGate().getPinCode();
if (rg.getGate().isIrisClosed() && pin != null && !pin.isBlank()) {
beginPinEntry(player, rg);
return;
}
boolean nowClosed = gateManager.toggleIrisShield(rg);
player.sendMessage(Component.text("Iris " + (nowClosed ? "closed." : "opened."),
nowClosed ? NamedTextColor.RED : NamedTextColor.GREEN));
player.playSound(player.getLocation(), Sound.BLOCK_IRON_DOOR_OPEN, 1f, nowClosed ? 0.7f : 1.3f);
}
private void beginPinEntry(Player player, RuntimeGate rg) {
cancelPending(player.getUniqueId());
PendingPin pending = new PendingPin(rg);
int timeout = gateManager.getConfig().pinTimeoutSeconds;
pending.timeoutTask = Bukkit.getScheduler().runTaskLater(plugin, () -> {
pendingPins.remove(player.getUniqueId());
if (player.isOnline()) {
player.sendMessage(Component.text("Pin entry timed out.", NamedTextColor.RED));
}
}, timeout * 20L);
pendingPins.put(player.getUniqueId(), pending);
player.sendMessage(Component.text("This iris is locked. Type the pin code in chat within "
+ timeout + "s.", NamedTextColor.YELLOW));
}
private void cancelPending(UUID playerId) {
PendingPin pending = pendingPins.remove(playerId);
if (pending != null && pending.timeoutTask != null) {
pending.timeoutTask.cancel();
}
}
@EventHandler(ignoreCancelled = true)
public void onChat(AsyncChatEvent event) {
Player player = event.getPlayer();
PendingPin pending = pendingPins.get(player.getUniqueId());
if (pending == null) return;
event.setCancelled(true);
String typed = PlainTextComponentSerializer.plainText().serialize(event.message()).trim();
Bukkit.getScheduler().runTask(plugin, () -> {
String expected = pending.gate.getGate().getPinCode();
if (expected != null && expected.equals(typed)) {
cancelPending(player.getUniqueId());
gateManager.openIrisShield(pending.gate);
player.sendMessage(Component.text("Correct. Iris opened.", NamedTextColor.GREEN));
player.playSound(player.getLocation(), Sound.BLOCK_IRON_DOOR_OPEN, 1f, 1.3f);
} else {
player.sendMessage(Component.text("Incorrect pin.", NamedTextColor.RED));
}
});
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
cancelPending(event.getPlayer().getUniqueId());
}
}
@@ -6,18 +6,30 @@ 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.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.util.Vector;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Walking into an open gate's water plane teleports the player to the connected gate. */
/**
* Walking into an open gate's event horizon teleports the player to the connected gate,
* arriving just past the destination's iris (never inside it - that would immediately
* re-trigger this same listener and bounce them straight back) and facing the same absolute
* direction they were already walking, so "enter forward" always means "exit forward".
*/
public class GateTeleportListener implements Listener {
private final StargatePlugin plugin;
private final GateManager gateManager;
private final CrossServerBridge crossServerBridge;
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
private static final long COOLDOWN_MS = 1500;
public GateTeleportListener(StargatePlugin plugin, GateManager gateManager, CrossServerBridge crossServerBridge) {
this.plugin = plugin;
@@ -32,6 +44,10 @@ public class GateTeleportListener implements Listener {
if (event.getFrom().getBlockX() == to.getBlockX() && event.getFrom().getBlockY() == to.getBlockY()
&& event.getFrom().getBlockZ() == to.getBlockZ()) return;
Player player = event.getPlayer();
Long until = cooldownUntil.get(player.getUniqueId());
if (until != null && System.currentTimeMillis() < until) return;
Block standing = to.getBlock();
List<RuntimeGate> gates = gateManager.all();
for (RuntimeGate rg : gates) {
@@ -39,28 +55,33 @@ public class GateTeleportListener implements Listener {
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);
teleport(player, rg);
return;
}
}
}
}
private void teleport(org.bukkit.entity.Player player, RuntimeGate entered) {
private void teleport(Player player, RuntimeGate entered) {
RuntimeGate dest = entered.getConnectedTo();
if (dest == null) return;
Location loc = player.getLocation();
Vector direction = loc.getDirection();
Vector velocity = player.getVelocity();
boolean remote = !dest.getGate().getServerId().equals(plugin.getServerId());
if (remote) {
crossServerBridge.sendPlayerThroughGate(player, dest.getGate());
crossServerBridge.sendPlayerThroughGate(player, dest.getGate(), loc.getYaw(), loc.getPitch());
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);
Location landing = gateManager.computeSafeLanding(dest, direction, loc.getYaw(), loc.getPitch());
if (landing == null) return;
cooldownUntil.put(player.getUniqueId(), System.currentTimeMillis() + COOLDOWN_MS);
player.teleport(landing);
player.setVelocity(velocity);
player.playSound(landing, org.bukkit.Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1f);
}
}
@@ -6,11 +6,12 @@ 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 dev.skywalker3200.stargate.paper.gate.RuntimeGate;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.PluginMessageListener;
import org.bukkit.util.Vector;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
@@ -19,7 +20,7 @@ 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.
* warped to the right spot once they land, facing the same direction they entered with.
*/
public class CrossServerBridge implements PluginMessageListener {
@@ -43,14 +44,16 @@ public class CrossServerBridge implements PluginMessageListener {
return enabled;
}
/** Asks the proxy to move this player to the backend server hosting {@code destGate}. */
public void sendPlayerThroughGate(Player player, Gate destGate) {
/** Asks the proxy to move this player to the backend server hosting {@code destGate}, carrying their look direction along. */
public void sendPlayerThroughGate(Player player, Gate destGate, float yaw, float pitch) {
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());
out.writeFloat(yaw);
out.writeFloat(pitch);
player.sendPluginMessage(plugin, StargateChannel.CHANNEL, out.toByteArray());
}
@@ -64,21 +67,23 @@ public class CrossServerBridge implements PluginMessageListener {
UUID playerId = UUID.fromString(in.readUTF());
UUID gateId = UUID.fromString(in.readUTF());
float yaw = in.readFloat();
float pitch = in.readFloat();
var rg = gateManager.getById(gateId);
RuntimeGate 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);
double yawRad = Math.toRadians(yaw);
Vector direction = new Vector(-Math.sin(yawRad), 0, Math.cos(yawRad));
Location landing = gateManager.computeSafeLanding(rg, direction, yaw, pitch);
if (landing == null) return;
p.teleport(landing);
});
} catch (Exception e) {
plugin.getLogger().warning("[Stargate] Failed to handle cross-server teleport message: " + e.getMessage());
+11 -5
View File
@@ -43,12 +43,15 @@ gate:
# than their ring-neighbours - the "elbow" points of the shape, same as a real
# Stargate's chevron placement - then swaps just those to chevron-lit-material while
# dialing/open, restoring whatever they looked like at rest when the gate closes.
chevron-lit-material: GILDED_BLACKSTONE
# The interior ("iris") reflects whether the gate is usable. Closed/idle, it's solid
# bedrock - nothing can walk or fall through it. Only while open does it become
# iris-open-material (the "event horizon") and let players/items pass.
chevron-lit-material: GLOWSTONE
# The interior has three looks, matching SG-1: idle (not connected) is just empty
# air - you can see straight through the ring. Connected, it's the event horizon
# (iris-open-material) UNLESS the iris shield is closed, in which case it shows
# iris-shield-material instead and blocks all travel even though the gate is active.
# The shield is toggled with a button placed directly below the control sign.
iris-idle-material: AIR
iris-open-material: WATER
iris-closed-material: BEDROCK
iris-shield-material: IRON_BLOCK
max-frame-blocks: 300
max-iris-blocks: 200
min-frame-blocks: 8
@@ -58,6 +61,9 @@ gate:
link-timeout-seconds: 30
# How far (in blocks) the punched frame block may be from the sign.
max-link-distance: 64
# If a gate has a pin code set (/sg pin <code>), this is how long a player has to
# type it in chat after pressing the iris button while the shield is closed.
pin-timeout-seconds: 20
dialing:
# Seconds the gate stays open (iris filled) before auto-closing if nobody walks through.
@@ -38,7 +38,7 @@ public class StargateVelocityPlugin {
// playerId -> (targetServerName, gateId) pending until they finish connecting
private final Map<UUID, PendingWarp> pending = new ConcurrentHashMap<>();
private record PendingWarp(String gateId) {}
private record PendingWarp(String gateId, float yaw, float pitch) {}
@Inject
public StargateVelocityPlugin(ProxyServer server, Logger logger) {
@@ -66,6 +66,8 @@ public class StargateVelocityPlugin {
UUID playerId = UUID.fromString(in.readUTF());
String targetServer = in.readUTF();
String gateId = in.readUTF();
float yaw = in.readFloat();
float pitch = in.readFloat();
Optional<Player> playerOpt = server.getPlayer(playerId);
Optional<RegisteredServer> targetOpt = server.getServer(targetServer);
@@ -74,7 +76,7 @@ public class StargateVelocityPlugin {
return;
}
pending.put(playerId, new PendingWarp(gateId));
pending.put(playerId, new PendingWarp(gateId, yaw, pitch));
playerOpt.get().createConnectionRequest(targetOpt.get()).connectWithIndication().thenAccept(success -> {
if (Boolean.TRUE.equals(success)) {
deliver(playerId, targetOpt.get());
@@ -102,6 +104,8 @@ public class StargateVelocityPlugin {
out.writeByte(StargateChannel.OP_TELEPORT_DELIVER);
out.writeUTF(playerId.toString());
out.writeUTF(warp.gateId());
out.writeFloat(warp.yaw());
out.writeFloat(warp.pitch());
connection.get().sendPluginMessage(channel, bytes.toByteArray());
} catch (Exception e) {
logger.warn("Failed to deliver stargate teleport", e);