diff --git a/README.md b/README.md index 3f9a7b6..33f6e9c 100644 --- a/README.md +++ b/README.md @@ -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 `), + 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 ` (look at a gate's sign) — requires that code in chat before a + closed iris shield will open on that gate - `/sg reload` diff --git a/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java b/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java index a990d79..3fbad3a 100644 --- a/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java +++ b/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java @@ -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 pendingGateByPlayer = new ConcurrentHashMap<>(); + private record PendingWarp(String gateId, float yaw, float pitch) {} + + private final Map 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()); diff --git a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java index fa3ce14..9fe0226 100644 --- a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java +++ b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java @@ -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() { diff --git a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java index e908e59..111af26 100644 --- a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java +++ b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java @@ -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 flags) { diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java index c5d5145..36057ce 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java @@ -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); diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java index a4a3362..c01d3f0 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java @@ -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 ", NamedTextColor.YELLOW)); + sender.sendMessage(Component.text("Usage: /sg ", 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 ", 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 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()); } diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java index 773e0c7..de9bf40 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java @@ -12,8 +12,9 @@ public class GateConfig { public final Set 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 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) { diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java index 65cea14..a6dfeea 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java @@ -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 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); + } } diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateIrisButtonListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateIrisButtonListener.java new file mode 100644 index 0000000..da8c46f --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateIrisButtonListener.java @@ -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 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()); + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java index 10f3429..d2bb688 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java @@ -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 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 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); } } diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java index c739026..d1e6ce9 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java @@ -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()); diff --git a/stargate-paper/src/main/resources/config.yml b/stargate-paper/src/main/resources/config.yml index 12d4ccf..1e7427a 100644 --- a/stargate-paper/src/main/resources/config.yml +++ b/stargate-paper/src/main/resources/config.yml @@ -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 ), 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. diff --git a/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java b/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java index 00c0aee..326945f 100644 --- a/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java +++ b/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java @@ -38,7 +38,7 @@ public class StargateVelocityPlugin { // playerId -> (targetServerName, gateId) pending until they finish connecting private final Map 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 playerOpt = server.getPlayer(playerId); Optional 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);