Fix chevron over-count, plane-lock diagonal frame connectivity, true one-way travel, iris shield independence

Chevron placement: the "protruding elbow" heuristic marked every
convex corner of a smooth ring as a chevron, which for a true
octagon/circle is *every* corner - producing 8 chevrons instead of a
deliberate subset. Replaced with a fixed, configurable count
(gate.chevron-count, default 7) evenly spaced along the ring's actual
geometric walk order.

Frame flood-fill leaking into unrelated structures: the diagonal
("stepped ring") connectivity fix from two commits ago used a full 3D
26-neighbor search, which let the flood leak sideways through the
ring's thickness into any other nearby structure sharing a frame
material (e.g. gilded blackstone used decoratively on a backdrop
wall), putting chevrons on it. The scan now bootstraps with a small
orthogonal-only flood to find the ring's plane first, then only
allows diagonal connectivity within that plane - never through depth.

One-way travel, properly this time: RuntimeGate gained an `outgoing`
flag, set true only on the gate that was actually dialed. The
teleport listener now only matches outgoing gates, so the destination
shows the event horizon but isn't walkable - stepping into it does
nothing, and only the dialing side can send a player anywhere.

Iris shield: it's a physical barrier independent of the wormhole, not
tied to "connected" state. Closed always means iris-shield-material
(default changed from IRON_BLOCK to BEDROCK per feedback), whether or
not the gate is even dialed right now. Only when the shield is open
does the idle/connected distinction (air vs event horizon) apply.
applyIrisMaterial() centralizes this so link, restart re-scan,
open/close, and the shield toggle all agree.
This commit is contained in:
Michael Burgess
2026-08-09 11:52:04 -04:00
parent b822389387
commit 8932dfed29
7 changed files with 163 additions and 154 deletions
@@ -12,6 +12,7 @@ public class GateConfig {
public final Set<Material> frameMaterials;
public final Material chevronLit;
public final int chevronCount;
public final Material irisIdleMaterial;
public final Material irisOpenMaterial;
public final Material irisShieldMaterial;
@@ -37,9 +38,10 @@ public class GateConfig {
this.frameMaterials = materials;
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GLOWSTONE, logger);
this.chevronCount = cfg.getInt("gate.chevron-count", 7);
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.irisShieldMaterial = matOr(cfg.getString("gate.iris-shield-material"), Material.IRON_BLOCK, logger);
this.irisShieldMaterial = matOr(cfg.getString("gate.iris-shield-material"), Material.BEDROCK, 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);
@@ -43,7 +43,7 @@ public class GateManager {
public void reloadConfig() {
this.config = new GateConfig(plugin.getConfig(), plugin.getLogger());
this.scanner = new GateStructureScanner(config.frameMaterials, config.chevronLit,
this.scanner = new GateStructureScanner(config.frameMaterials, config.chevronLit, config.chevronCount,
config.maxFrameBlocks, config.maxIrisBlocks, config.minFrameBlocks);
}
@@ -61,18 +61,17 @@ public class GateManager {
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(linked);
if (result.isSuccess()) {
structure = result.structure;
// 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.irisIdleMaterial);
}
} else {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName()
+ "' - it may have been damaged (" + result.failureReason + ")");
}
}
}
// a restart can't preserve "connected" runtime state, so every gate comes back
// idle - applyIrisMaterial() still shows the shield if it was left closed, since
// that's independent of the wormhole being active
RuntimeGate rg = new RuntimeGate(gate, structure);
if (structure != null) applyIrisMaterial(rg);
gatesById.put(gate.getId(), rg);
if (gate.getServerId().equals(plugin.getServerId())) {
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
@@ -178,9 +177,6 @@ public class GateManager {
return LinkResult.fail(result.failureReason);
}
GateStructure structure = result.structure;
for (Block b : structure.getIris()) {
b.setType(config.irisIdleMaterial);
}
Location exit = computeExitLocation(structure, clickedBlock.getWorld(), facing);
Gate gate = new Gate(UUID.randomUUID(), pending.name, pending.network, plugin.getServerId(),
@@ -190,6 +186,7 @@ public class GateManager {
clickedBlock.getX(), clickedBlock.getY(), clickedBlock.getZ());
RuntimeGate rg = new RuntimeGate(gate, structure);
applyIrisMaterial(rg);
gatesById.put(gate.getId(), rg);
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
storage.saveGate(gate);
@@ -262,7 +259,9 @@ public class GateManager {
Runnable finish = () -> {
openGate(from, to);
from.setOutgoing(true);
openGate(to, from);
to.setOutgoing(false);
};
if (chevrons.isEmpty()) {
@@ -313,12 +312,11 @@ public class GateManager {
if (gate.getDialTask() != null) { gate.getDialTask().cancel(); gate.setDialTask(null); }
if (gate.getCloseTask() != null) { gate.getCloseTask().cancel(); gate.setCloseTask(null); }
gate.setOpen(false);
gate.setOutgoing(false);
RuntimeGate other = gate.getConnectedTo();
gate.setConnectedTo(null);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(config.irisIdleMaterial);
}
applyIrisMaterial(gate);
for (GateStructure.ChevronSlot c : gate.getStructure().getChevrons()) {
c.getBlock().setType(c.getRestingMaterial());
}
@@ -334,9 +332,19 @@ public class GateManager {
}
}
/** Sets the iris blocks to whatever the gate's current connected/shield state implies. */
/**
* Sets the iris blocks to whatever the gate's current state implies. The shield is a
* physical barrier independent of the wormhole: closed means closed - solid, blocking
* everything - whether or not the gate is even connected right now. Only when the shield
* is open does the connected/idle distinction (event horizon vs plain air) matter.
*/
private void applyIrisMaterial(RuntimeGate gate) {
Material mat = gate.getGate().isIrisClosed() ? config.irisShieldMaterial : config.irisOpenMaterial;
Material mat;
if (gate.getGate().isIrisClosed()) {
mat = config.irisShieldMaterial;
} else {
mat = gate.isOpen() ? config.irisOpenMaterial : config.irisIdleMaterial;
}
for (Block b : gate.getStructure().getIris()) {
b.setType(mat);
if (b.getBlockData() instanceof Levelled lvl) {
@@ -348,12 +356,12 @@ public class GateManager {
// ---- Iris shield ----
/** Flips the iris shield and, if the gate is currently connected, updates the blocks immediately. Returns the new closed-state. */
/** Flips the iris shield and 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) {
if (rg.getStructure() != null) {
applyIrisMaterial(rg);
}
return nowClosed;
@@ -363,7 +371,7 @@ public class GateManager {
public void openIrisShield(RuntimeGate rg) {
rg.getGate().setIrisClosed(false);
saveGate(rg);
if (rg.isOpen() && rg.getStructure() != null) {
if (rg.getStructure() != null) {
applyIrisMaterial(rg);
}
}
@@ -16,29 +16,14 @@ import java.util.Set;
/**
* Flood-fill scanner that discovers a gate's physical structure starting from a punched
* frame block: the connected frame ring (any size/shape the builder makes), the enclosed
* interior ("iris"), and - unlike naive even-spacing - the ring's actual protruding "elbow"
* points, picked out geometrically, which is where a real Stargate's chevrons sit.
* interior ("iris"), and a fixed number of chevrons evenly spaced around the ring's actual
* geometric perimeter.
*/
public class GateStructureScanner {
// Round/octagonal rings built the usual Minecraft way step diagonally at the corners - two
// frame blocks touching only edge-to-edge (or even just corner-to-corner), not face-to-face.
// The frame search has to follow those too, or a stepped ring gets treated as several
// disconnected fragments instead of one structure.
private static final int[][] ALL_26_OFFSETS = buildAll26Offsets();
private static int[][] buildAll26Offsets() {
List<int[]> offsets = new ArrayList<>();
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
for (int dz = -1; dz <= 1; dz++) {
if (dx == 0 && dy == 0 && dz == 0) continue;
offsets.add(new int[]{dx, dy, dz});
}
}
}
return offsets.toArray(new int[0][]);
}
private static final BlockFace[] ORTHOGONAL = {
BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
};
public static final class ScanResult {
public final GateStructure structure; // null on failure
@@ -57,14 +42,16 @@ public class GateStructureScanner {
private final Set<Material> frameMaterials;
private final Material chevronLitMaterial;
private final int chevronCount;
private final int maxFrameBlocks;
private final int maxIrisBlocks;
private final int minFrameBlocks;
public GateStructureScanner(Set<Material> frameMaterials, Material chevronLitMaterial,
public GateStructureScanner(Set<Material> frameMaterials, Material chevronLitMaterial, int chevronCount,
int maxFrameBlocks, int maxIrisBlocks, int minFrameBlocks) {
this.frameMaterials = frameMaterials;
this.chevronLitMaterial = chevronLitMaterial;
this.chevronCount = chevronCount;
this.maxFrameBlocks = maxFrameBlocks;
this.maxIrisBlocks = maxIrisBlocks;
this.minFrameBlocks = minFrameBlocks;
@@ -84,6 +71,16 @@ public class GateStructureScanner {
return ScanResult.fail("That block isn't listed in gate.frame-materials.");
}
// Diagonal ("stepped") connectivity must only ever apply within the ring's own flat
// plane - never through its thickness - or the flood can leak sideways into an
// unrelated nearby structure built from the same materials (a decorative backdrop
// wall, for instance). We don't know the plane yet, so bootstrap it with a small,
// orthogonal-only (face-to-face) flood first, which can't leak through depth at all,
// then lock diagonal moves to that plane for the real flood.
List<Block> bootstrap = orthogonalFlood(seed, 24);
BlockFace[] inPlane = inPlaneDirections(bootstrap);
int[][] planeOffsets = planeOffsetsFor(inPlane);
Set<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
@@ -99,7 +96,7 @@ public class GateStructureScanner {
+ "Raise max-frame-blocks or make the frame materials more specific.");
}
for (int[] d : ALL_26_OFFSETS) {
for (int[] d : planeOffsets) {
Block next = cur.getRelative(d[0], d[1], d[2]);
long k = key(next);
if (frameKeys.contains(k)) continue;
@@ -115,12 +112,6 @@ public class GateStructureScanner {
+ minFrameBlocks + " (gate.min-frame-blocks).");
}
// A free-standing, one-block-thick ring has nothing behind or in front of it, so
// interior candidates must stay confined to the ring's own plane - otherwise "inside"
// immediately leaks out the front/back into the open world. Find the thin axis (the
// one frame blocks barely vary along) and only search/flood the other two.
BlockFace[] inPlane = inPlaneDirections(frameBlocks);
Set<Long> triedSeeds = new HashSet<>();
boolean triedAnyInterior = false;
for (Block frameBlock : frameBlocks) {
@@ -134,7 +125,7 @@ public class GateStructureScanner {
triedAnyInterior = true;
List<Block> iris = floodInterior(candidate, frameKeys, inPlane);
if (iris != null && !iris.isEmpty()) {
return ScanResult.ok(new GateStructure(frameBlocks, pickChevrons(frameBlocks, iris), iris));
return ScanResult.ok(new GateStructure(frameBlocks, pickChevrons(frameBlocks, inPlane), iris));
}
}
}
@@ -148,6 +139,54 @@ public class GateStructureScanner {
+ "gate.max-iris-blocks (" + maxIrisBlocks + ").");
}
/** Small orthogonal-only (face-to-face) flood used purely to sample the ring's shape before we know its plane. */
private List<Block> orthogonalFlood(Block seed, int cap) {
Set<Long> visited = new HashSet<>();
List<Block> found = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
queue.add(seed);
visited.add(key(seed));
while (!queue.isEmpty() && found.size() < cap) {
Block cur = queue.poll();
found.add(cur);
for (BlockFace face : ORTHOGONAL) {
Block next = cur.getRelative(face);
long k = key(next);
if (visited.contains(k)) continue;
if (isFrameMaterial(next.getType())) {
visited.add(k);
queue.add(next);
}
}
}
return found;
}
/** The 6 orthogonal offsets plus the 4 diagonals that stay within the given in-plane directions. */
private int[][] planeOffsetsFor(BlockFace[] inPlane) {
List<int[]> offsets = new ArrayList<>();
for (BlockFace f : ORTHOGONAL) {
offsets.add(new int[]{f.getModX(), f.getModY(), f.getModZ()});
}
// Only two of the three axes appear in inPlane (the other is the thickness axis);
// add the 4 corner combinations of those two axes.
boolean usesX = false, usesY = false, usesZ = false;
for (BlockFace f : inPlane) {
if (f.getModX() != 0) usesX = true;
if (f.getModY() != 0) usesY = true;
if (f.getModZ() != 0) usesZ = true;
}
for (int a = -1; a <= 1; a += 2) {
for (int b = -1; b <= 1; b += 2) {
if (usesX && usesY) offsets.add(new int[]{a, b, 0});
else if (usesX && usesZ) offsets.add(new int[]{a, 0, b});
else if (usesY && usesZ) offsets.add(new int[]{0, a, b});
}
}
return offsets.toArray(new int[0][]);
}
private List<Block> floodInterior(Block seed, Set<Long> frameKeys, BlockFace[] inPlane) {
Set<Long> visited = new HashSet<>();
List<Block> interior = new ArrayList<>();
@@ -194,76 +233,38 @@ public class GateStructureScanner {
}
/**
* Picks chevrons at the ring's actual protruding "elbow" points, the way a real Stargate's
* chevrons sit - not evenly spaced by scan order. Works by walking the ring in geometric
* order (each frame block should have exactly two ring neighbours) and marking local maxima
* of distance from the interior's centroid, i.e. points that stick out further than their
* immediate neighbours on the ring.
* Picks {@code chevronCount} chevrons evenly spaced around the ring's actual geometric
* perimeter - walks the ring in order (each frame block should have exactly two ring
* neighbours) and takes evenly-spaced indices from that walk, not raw scan-discovery order.
*/
private List<GateStructure.ChevronSlot> pickChevrons(List<Block> frameBlocks, List<Block> iris) {
// The ring is essentially 2D (one block thick); find which world axis is the thin one.
int minX = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE;
int minZ = Integer.MAX_VALUE, maxZ = Integer.MIN_VALUE;
for (Block b : frameBlocks) {
minX = Math.min(minX, b.getX()); maxX = Math.max(maxX, b.getX());
minZ = Math.min(minZ, b.getZ()); maxZ = Math.max(maxZ, b.getZ());
}
boolean horizontalIsX = (maxX - minX) >= (maxZ - minZ);
private List<GateStructure.ChevronSlot> pickChevrons(List<Block> frameBlocks, BlockFace[] inPlane) {
boolean horizontalIsX = false;
for (BlockFace f : inPlane) if (f.getModX() != 0) horizontalIsX = true;
Map<Long, Block> plane = new HashMap<>();
Map<Block, int[]> coords = new HashMap<>();
for (Block b : frameBlocks) {
int col = horizontalIsX ? b.getX() : b.getZ();
int row = b.getY();
long k = (((long) row) << 32) ^ (col & 0xFFFFFFFFL);
plane.putIfAbsent(k, b);
coords.put(b, new int[]{row, col});
}
List<Block> ordered = walkRing(frameBlocks, plane, horizontalIsX);
if (ordered == null || ordered.size() < 4) {
return fallbackEvenlySpaced(frameBlocks);
}
List<Block> ring = (ordered != null && ordered.size() >= 4) ? ordered : frameBlocks;
double centroidRow, centroidCol;
if (!iris.isEmpty()) {
long sumRow = 0, sumCol = 0;
for (Block b : iris) {
sumRow += b.getY();
sumCol += horizontalIsX ? b.getX() : b.getZ();
}
centroidRow = (double) sumRow / iris.size();
centroidCol = (double) sumCol / iris.size();
} else {
long sumRow = 0, sumCol = 0;
for (Block b : frameBlocks) {
sumRow += b.getY();
sumCol += horizontalIsX ? b.getX() : b.getZ();
}
centroidRow = (double) sumRow / frameBlocks.size();
centroidCol = (double) sumCol / frameBlocks.size();
int count = Math.max(0, Math.min(chevronCount, ring.size()));
List<GateStructure.ChevronSlot> slots = new ArrayList<>(count);
if (count == 0) return slots;
double step = ring.size() / (double) count;
Set<Integer> chosen = new HashSet<>();
for (int i = 0; i < count; i++) {
int idx = (int) Math.round(i * step) % ring.size();
while (chosen.contains(idx)) idx = (idx + 1) % ring.size();
chosen.add(idx);
Block b = ring.get(idx);
slots.add(new GateStructure.ChevronSlot(b, b.getType()));
}
int n = ordered.size();
double[] dist = new double[n];
for (int i = 0; i < n; i++) {
int[] rc = coords.get(ordered.get(i));
double dr = rc[0] - centroidRow;
double dc = rc[1] - centroidCol;
dist[i] = dr * dr + dc * dc;
}
List<GateStructure.ChevronSlot> chevrons = new ArrayList<>();
for (int i = 0; i < n; i++) {
double prev = dist[(i - 1 + n) % n];
double next = dist[(i + 1) % n];
if (dist[i] > prev && dist[i] >= next) {
Block b = ordered.get(i);
chevrons.add(new GateStructure.ChevronSlot(b, b.getType()));
}
}
return chevrons.isEmpty() ? fallbackEvenlySpaced(frameBlocks) : chevrons;
return slots;
}
private static final int[][] PLANE_8_OFFSETS = {
@@ -302,18 +303,6 @@ public class GateStructureScanner {
return ordered.size() == frameBlocks.size() ? ordered : null;
}
private List<GateStructure.ChevronSlot> fallbackEvenlySpaced(List<Block> frameBlocks) {
int count = Math.min(6, frameBlocks.size());
List<GateStructure.ChevronSlot> slots = new ArrayList<>(count);
if (count == 0) return slots;
double step = frameBlocks.size() / (double) count;
for (int i = 0; i < count; i++) {
Block b = frameBlocks.get((int) Math.round(i * step) % frameBlocks.size());
slots.add(new GateStructure.ChevronSlot(b, b.getType()));
}
return slots;
}
private long key(Block b) {
return (((long) b.getX() & 0x3FFFFFF) << 38) | (((long) (b.getY() + 512) & 0xFFF) << 26) | ((long) b.getZ() & 0x3FFFFFF);
}
@@ -9,6 +9,7 @@ public class RuntimeGate {
private final Gate gate;
private GateStructure structure; // null if structure could not be (re)scanned
private boolean open = false;
private boolean outgoing = false; // true only for the gate that was dialed - travel is one-way
private RuntimeGate connectedTo = null;
private BukkitTask closeTask;
private BukkitTask dialTask;
@@ -24,6 +25,8 @@ public class RuntimeGate {
public void setStructure(GateStructure structure) { this.structure = structure; }
public boolean isOpen() { return open; }
public void setOpen(boolean open) { this.open = open; }
public boolean isOutgoing() { return outgoing; }
public void setOutgoing(boolean outgoing) { this.outgoing = outgoing; }
public RuntimeGate getConnectedTo() { return connectedTo; }
public void setConnectedTo(RuntimeGate connectedTo) { this.connectedTo = connectedTo; }
public BukkitTask getCloseTask() { return closeTask; }
@@ -18,10 +18,12 @@ import java.util.Map;
import java.util.UUID;
/**
* 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".
* Walking into the DIALING gate's event horizon teleports the player to the connected gate,
* arriving just past its iris (never inside it) and facing the same absolute direction they
* were already walking, so "enter forward" always means "exit forward". Travel is strictly
* one-way: only the gate that was dialed (outgoing = true) can send anyone anywhere - stepping
* into the receiving gate's horizon does nothing, matching the wormhole only running one
* direction at a time.
*/
public class GateTeleportListener implements Listener {
@@ -51,7 +53,7 @@ public class GateTeleportListener implements Listener {
Block standing = to.getBlock();
List<RuntimeGate> gates = gateManager.all();
for (RuntimeGate rg : gates) {
if (!rg.isOpen() || rg.getStructure() == null || rg.getConnectedTo() == null) continue;
if (!rg.isOpen() || !rg.isOutgoing() || rg.getStructure() == null || rg.getConnectedTo() == null) continue;
for (Block iris : rg.getStructure().getIris()) {
if (iris.getX() == standing.getX() && iris.getY() == standing.getY() && iris.getZ() == standing.getZ()
&& iris.getWorld().equals(standing.getWorld())) {
+17 -11
View File
@@ -31,7 +31,10 @@ gate:
# Mix and match to match your build (the default matches an obsidian/gilded-blackstone
# ring). The ring can be any size or shape, as long as it's a single connected loop
# made only of these materials with a fully sealed hollow interior - no fixed template
# or size is required.
# or size is required. "Connected" includes diagonal stepping (round rings need that
# at their corners), but only within the ring's own flat plane - a nearby structure
# built from the same materials won't get pulled in just because it touches the ring
# through its thickness.
frame-materials:
- OBSIDIAN
- GOLD_BLOCK
@@ -39,19 +42,22 @@ gate:
- BIRCH_PLANKS
- OAK_PLANKS
# Chevrons aren't a separate material you place. The plugin flood-fills the ring,
# walks it in geometric order, and picks out the blocks that stick further outward
# 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.
# walks it in geometric order, and picks this many blocks, evenly spaced around the
# ring's actual perimeter (not raw scan order) - 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: 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.
chevron-count: 7
# The interior's look is driven by two independent things, matching SG-1:
# - The iris shield: a physical barrier toggled with a button placed directly below
# the control sign. Closed means closed - iris-shield-material, solid, blocking
# everything - REGARDLESS of whether a wormhole is even active right now.
# - Whether the shield is open: then it's iris-open-material (the event horizon)
# if the gate is actively connected, or iris-idle-material (just empty air, you
# see straight through) if it's just sitting there dialed to nothing.
iris-idle-material: AIR
iris-open-material: WATER
iris-shield-material: IRON_BLOCK
iris-shield-material: BEDROCK
max-frame-blocks: 300
max-iris-blocks: 200
min-frame-blocks: 8