Decouple the control sign from the gate frame: place then punch to link

Sign creation no longer scans the block it's mounted on. Instead,
placing a "[Stargate]" sign starts a pending link (shown as "Punch the
gate" on the sign) and the next block the owner left-clicks within
gate.link-timeout-seconds and gate.max-link-distance becomes the scan
seed. This lets the sign act like a DHD console standing apart from
the gate instead of being physically attached to the frame.

- Gate now stores the punched link block's coordinates (link_x/y/z)
  so restarts re-scan from there instead of deriving a seed from the
  sign's attachment, which no longer applies.
- New GateLinkListener handles the punch and reports scan failures
  via the existing diagnostic messages; also clears pending state on
  disconnect.
- Exit-location facing is now derived from the punched frame block's
  position relative to the iris, since a WallSign facing is no longer
  guaranteed to exist or be relevant.
This commit is contained in:
Michael Burgess
2026-08-09 10:32:46 -04:00
parent 89d4fa0b63
commit 317602c00e
11 changed files with 254 additions and 86 deletions
@@ -44,6 +44,7 @@ public class StargatePlugin extends JavaPlugin {
}
getServer().getPluginManager().registerEvents(new SignCreateListener(this, gateManager), this);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateLinkListener(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);
@@ -21,6 +21,8 @@ public class GateConfig {
public final int chevronTickDelay;
public final boolean playSounds;
public final String defaultNetwork;
public final int linkTimeoutSeconds;
public final int maxLinkDistance;
public GateConfig(FileConfiguration cfg, Logger logger) {
Set<Material> materials = new HashSet<>();
@@ -42,6 +44,8 @@ public class GateConfig {
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");
this.linkTimeoutSeconds = cfg.getInt("gate.link-timeout-seconds", 30);
this.maxLinkDistance = cfg.getInt("gate.max-link-distance", 64);
}
private Material matOr(String s, Material fallback, Logger logger) {
@@ -50,13 +50,10 @@ public class GateManager {
for (Gate gate : storage.loadAll()) {
GateStructure structure = null;
if (gate.getServerId().equals(plugin.getServerId())) {
World world = Bukkit.getWorld(gate.getSignWorld());
World world = Bukkit.getWorld(gate.getWorld());
if (world != null) {
Block signBlock = world.getBlockAt(gate.getSignX(), gate.getSignY(), gate.getSignZ());
Block attached = attachedFrameBlock(signBlock);
if (attached != null) {
structure = scanner.scan(attached);
}
Block linked = world.getBlockAt(gate.getLinkX(), gate.getLinkY(), gate.getLinkZ());
structure = scanner.scan(linked);
if (structure == null) {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName() + "' - it may have been damaged.");
}
@@ -71,16 +68,6 @@ public class GateManager {
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;
}
@@ -108,42 +95,90 @@ public class GateManager {
.collect(Collectors.toList());
}
private String lastCreateFailureReason;
// ---- Sign placement -> punch-to-link creation flow ----
/** Set only when {@link #createGate} just returned null - explains why the scan failed. */
public String getLastCreateFailureReason() {
return lastCreateFailureReason;
private final Map<UUID, PendingGateCreation> pendingCreations = new HashMap<>();
public boolean hasPendingCreation(UUID playerId) {
return pendingCreations.containsKey(playerId);
}
public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags) {
return createGate(signBlock, network, name, owner, flags, null);
/** Called when a player places a "[Stargate]" sign; starts the countdown to punch a frame block. */
public void beginPendingCreation(Player player, Block signBlock, String network, String name, UUID owner,
EnumSet<Gate.Flag> flags, String fixedDestination) {
cancelPending(player.getUniqueId());
PendingGateCreation pending = new PendingGateCreation(signBlock.getLocation(), network, name, owner, flags, fixedDestination);
pending.timeoutTask = Bukkit.getScheduler().runTaskLater(plugin, () -> {
pendingCreations.remove(player.getUniqueId());
dev.skywalker3200.stargate.paper.util.SignRenderer.renderExpired(signBlock);
if (player.isOnline()) {
player.sendMessage(net.kyori.adventure.text.Component.text(
"Stargate sign link timed out. Break and replace the sign to try again.",
net.kyori.adventure.text.format.NamedTextColor.RED));
}
}, config.linkTimeoutSeconds * 20L);
pendingCreations.put(player.getUniqueId(), pending);
}
public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags, String fixedDestination) {
lastCreateFailureReason = null;
Block attached = attachedFrameBlock(signBlock);
if (attached == null) {
lastCreateFailureReason = "Couldn't determine which block the sign is attached to.";
return null;
public void cancelPending(UUID playerId) {
PendingGateCreation pending = pendingCreations.remove(playerId);
if (pending != null && pending.timeoutTask != null) {
pending.timeoutTask.cancel();
}
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(attached);
}
/** Result of punching a block while a gate-creation link is pending. */
public static final class LinkResult {
public final RuntimeGate gate; // non-null on success
public final String failureReason; // non-null on failure
private LinkResult(RuntimeGate gate, String failureReason) {
this.gate = gate;
this.failureReason = failureReason;
}
static LinkResult ok(RuntimeGate g) { return new LinkResult(g, null); }
static LinkResult fail(String reason) { return new LinkResult(null, reason); }
}
/**
* Tries to link a pending sign to the structure reachable from the punched block.
* Returns null if the player has no pending creation (i.e. this click isn't ours to handle).
*/
public LinkResult attemptLink(Player player, Block clickedBlock) {
PendingGateCreation pending = pendingCreations.get(player.getUniqueId());
if (pending == null) return null;
Block signRef = pending.signLocation.getBlock();
if (!clickedBlock.getWorld().equals(pending.signLocation.getWorld())) {
return LinkResult.fail("That's a different world than the sign.");
}
double distance = clickedBlock.getLocation().distance(pending.signLocation);
if (distance > config.maxLinkDistance) {
return LinkResult.fail("That block is " + (int) distance + " blocks from the sign - gate.max-link-distance is "
+ config.maxLinkDistance + ".");
}
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(clickedBlock);
if (!result.isSuccess()) {
lastCreateFailureReason = result.failureReason;
return null;
return LinkResult.fail(result.failureReason);
}
GateStructure structure = result.structure;
Location exit = computeExitLocation(structure, signBlock);
Gate gate = new Gate(UUID.randomUUID(), name, network, plugin.getServerId(),
Location exit = computeExitLocation(structure, clickedBlock);
Gate gate = new Gate(UUID.randomUUID(), pending.name, pending.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, fixedDestination);
signRef.getX(), signRef.getY(), signRef.getZ(), signRef.getWorld().getName(),
signBlockFacing(signRef), pending.owner, pending.flags, pending.fixedDestination,
clickedBlock.getX(), clickedBlock.getY(), clickedBlock.getZ());
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;
cancelPending(player.getUniqueId());
return LinkResult.ok(rg);
}
public void destroyGate(RuntimeGate rg) {
@@ -157,11 +192,12 @@ public class GateManager {
storage.saveGate(rg.getGate());
}
private Location computeExitLocation(GateStructure structure, Block signBlock) {
/** frameBlock is whichever block the player punched to link the gate - used only to pick a facing. */
private Location computeExitLocation(GateStructure structure, Block frameBlock) {
List<Block> iris = structure.getIris();
World world = signBlock.getWorld();
World world = frameBlock.getWorld();
if (iris.isEmpty()) {
return signBlock.getLocation().add(0, 0, 0);
return frameBlock.getLocation();
}
long sumX = 0, sumZ = 0;
int minY = Integer.MAX_VALUE;
@@ -172,23 +208,15 @@ public class GateManager {
}
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;
};
// Face away from the punched frame block, out through the opposite side of the iris -
// a reasonable guess at "outward" without relying on a sign's wall orientation.
double dx = avgX - (frameBlock.getX() + 0.5);
double dz = avgZ - (frameBlock.getZ() + 0.5);
float yaw = (float) Math.toDegrees(Math.atan2(-dx, dz));
if (dx == 0 && dz == 0) yaw = 0f;
return new Location(world, avgX, minY + 1, avgZ, yaw, 0f);
}
private String signBlockFacing(Block signBlock) {
@@ -0,0 +1,34 @@
package dev.skywalker3200.stargate.paper.gate;
import dev.skywalker3200.stargate.common.model.Gate;
import org.bukkit.Location;
import org.bukkit.scheduler.BukkitTask;
import java.util.EnumSet;
import java.util.UUID;
/**
* A "[Stargate]" sign that's been placed but not yet linked to a physical gate structure -
* the player has until the timeout to left-click ("punch") a frame block, acting like a DHD
* that doesn't have to be built touching the gate itself.
*/
public class PendingGateCreation {
public final Location signLocation;
public final String network;
public final String name;
public final UUID owner;
public final EnumSet<Gate.Flag> flags;
public final String fixedDestination;
public BukkitTask timeoutTask;
public PendingGateCreation(Location signLocation, String network, String name, UUID owner,
EnumSet<Gate.Flag> flags, String fixedDestination) {
this.signLocation = signLocation;
this.network = network;
this.name = name;
this.owner = owner;
this.flags = flags;
this.fixedDestination = fixedDestination;
}
}
@@ -0,0 +1,55 @@
package dev.skywalker3200.stargate.paper.listener;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import dev.skywalker3200.stargate.paper.util.SignRenderer;
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.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
/**
* While a "[Stargate]" sign is waiting to be linked, the next block the owner punches
* (left-clicks) is scanned as the gate's frame - lets the control sign act like a DHD
* sitting away from the gate itself instead of being mounted directly on it.
*/
public class GateLinkListener implements Listener {
private final GateManager gateManager;
public GateLinkListener(GateManager gateManager) {
this.gateManager = gateManager;
}
@EventHandler(ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.LEFT_CLICK_BLOCK) return;
var player = event.getPlayer();
if (!gateManager.hasPendingCreation(player.getUniqueId())) return;
Block clicked = event.getClickedBlock();
if (clicked == null) return;
event.setCancelled(true);
GateManager.LinkResult result = gateManager.attemptLink(player, clicked);
if (result == null) return; // pending vanished between the check and now (e.g. timed out this tick)
if (result.gate == null) {
player.sendMessage(Component.text("Couldn't link there.", NamedTextColor.RED));
player.sendMessage(Component.text(result.failureReason, NamedTextColor.GRAY));
return;
}
SignRenderer.render(result.gate, gateManager);
player.sendMessage(Component.text("Stargate '" + result.gate.getGate().getName() + "' linked on network '"
+ result.gate.getGate().getNetwork() + "'.", NamedTextColor.AQUA));
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
gateManager.cancelPending(event.getPlayer().getUniqueId());
}
}
@@ -3,7 +3,6 @@ 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;
@@ -58,19 +57,12 @@ public class SignCreateListener implements Listener {
}
}
RuntimeGate rg = gateManager.createGate(event.getBlock(), network, name, player.getUniqueId(), flags, fixedDestination);
if (rg == null) {
String reason = gateManager.getLastCreateFailureReason();
player.sendMessage(Component.text("No valid gate structure found.", NamedTextColor.RED));
if (reason != null) {
player.sendMessage(Component.text(reason, NamedTextColor.GRAY));
}
resetLine(event);
return;
}
int timeout = gateManager.getConfig().linkTimeoutSeconds;
gateManager.beginPendingCreation(player, event.getBlock(), network, name, player.getUniqueId(), flags, fixedDestination);
SignRenderer.renderPending(event, name, network, timeout);
player.sendMessage(Component.text("Stargate '" + name + "' created on network '" + network + "'.", NamedTextColor.AQUA));
SignRenderer.render(event, rg, gateManager);
player.sendMessage(Component.text("Sign placed. Left-click (punch) any block of the gate's frame within "
+ timeout + "s to link it.", NamedTextColor.AQUA));
}
private String stripped(Component c) {
@@ -11,16 +11,27 @@ import org.bukkit.event.block.SignChangeEvent;
import java.util.List;
/** Renders a gate's current state (name, network, selected destination) onto its control sign. */
/** Renders a gate sign's state: pending link, expired, or a live gate's name/network/destination. */
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(""));
/** Shown immediately when the "[Stargate]" sign is placed, before it's linked to a structure. */
public static void renderPending(SignChangeEvent event, String name, String network, int timeoutSeconds) {
event.line(0, Component.text(name, NamedTextColor.DARK_AQUA));
event.line(1, Component.text(network, NamedTextColor.GRAY));
event.line(2, Component.text("Punch the gate", NamedTextColor.YELLOW));
event.line(3, Component.text("(" + timeoutSeconds + "s)", NamedTextColor.YELLOW));
}
/** Shown if the player never punched a frame block in time. */
public static void renderExpired(Block signBlock) {
if (!(signBlock.getState() instanceof Sign sign)) return;
sign.getSide(Side.FRONT).line(0, Component.text("[Stargate]", NamedTextColor.DARK_GRAY));
sign.getSide(Side.FRONT).line(1, Component.text(""));
sign.getSide(Side.FRONT).line(2, Component.text("link expired", NamedTextColor.RED));
sign.getSide(Side.FRONT).line(3, Component.text(""));
sign.update(true, false);
}
public static void render(RuntimeGate rg, GateManager gateManager) {
@@ -46,6 +46,12 @@ gate:
max-frame-blocks: 300
max-iris-blocks: 200
min-frame-blocks: 8
# After placing a "[Stargate]" sign, the owner has this long to left-click (punch) a
# block of the actual gate frame to link it - the sign itself can sit anywhere, like a
# DHD console away from the gate, instead of being mounted directly on the frame.
link-timeout-seconds: 30
# How far (in blocks) the punched frame block may be from the sign.
max-link-distance: 64
dialing:
# Seconds the gate stays open (iris filled) before auto-closing if nobody walks through.