Fix interior flood leaking out the front/back of free-standing rings

A one-block-thick ring standing free (nothing built behind it, like
every reference screenshot) has open air directly in front of and
behind the interior. The iris/interior flood-fill searched all 6
directions, so it always escaped through that open air and flooded
the surrounding world instead of staying inside the ring - reliably
producing "interior isn't fully enclosed" regardless of how sound the
ring actually was.

Fixed by detecting which world axis the ring is thinnest along (same
idea already used for chevron placement) and restricting both the
interior candidate search and the flood itself to the other two axes,
so it can only explore within the ring's own plane.

Also fixed iris material semantics per feedback: the interior is no
longer just air when idle. gate.iris-material is now split into
iris-closed-material (default BEDROCK - solid, blocks all passage)
and iris-open-material (default WATER, the event horizon), applied
at link time, on open/close, and after a restart re-scan.
This commit is contained in:
Michael Burgess
2026-08-09 11:19:55 -04:00
parent 282c8cfdbb
commit 190f875d23
5 changed files with 59 additions and 16 deletions
@@ -12,7 +12,8 @@ public class GateConfig {
public final Set<Material> frameMaterials;
public final Material chevronLit;
public final Material irisMaterial;
public final Material irisOpenMaterial;
public final Material irisClosedMaterial;
public final int maxFrameBlocks;
public final int maxIrisBlocks;
public final int minFrameBlocks;
@@ -34,7 +35,8 @@ public class GateConfig {
this.frameMaterials = materials;
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GILDED_BLACKSTONE, logger);
this.irisMaterial = matOr(cfg.getString("gate.iris-material"), Material.WATER, 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.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);
@@ -57,6 +57,11 @@ 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
for (Block b : structure.getIris()) {
b.setType(config.irisClosedMaterial);
}
} else {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName()
+ "' - it may have been damaged (" + result.failureReason + ")");
@@ -169,6 +174,9 @@ public class GateManager {
return LinkResult.fail(result.failureReason);
}
GateStructure structure = result.structure;
for (Block b : structure.getIris()) {
b.setType(config.irisClosedMaterial);
}
Location exit = computeExitLocation(structure, clickedBlock.getWorld(), facing);
Gate gate = new Gate(UUID.randomUUID(), pending.name, pending.network, plugin.getServerId(),
@@ -281,7 +289,7 @@ public class GateManager {
gate.setConnectedTo(connectedTo);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(config.irisMaterial);
b.setType(config.irisOpenMaterial);
if (b.getBlockData() instanceof Levelled lvl) {
lvl.setLevel(0);
b.setBlockData(lvl);
@@ -311,7 +319,7 @@ public class GateManager {
gate.setConnectedTo(null);
if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) {
b.setType(org.bukkit.Material.AIR);
b.setType(config.irisClosedMaterial);
}
for (GateStructure.ChevronSlot c : gate.getStructure().getChevrons()) {
c.getBlock().setType(c.getRestingMaterial());
@@ -21,10 +21,6 @@ import java.util.Set;
*/
public class GateStructureScanner {
private static final BlockFace[] NEIGHBORS = {
BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
};
// 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
@@ -119,10 +115,16 @@ 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) {
for (BlockFace face : NEIGHBORS) {
for (BlockFace face : inPlane) {
Block candidate = frameBlock.getRelative(face);
long ck = key(candidate);
if (frameKeys.contains(ck) || triedSeeds.contains(ck)) continue;
@@ -130,7 +132,7 @@ public class GateStructureScanner {
if (candidate.getType().isSolid()) continue;
triedAnyInterior = true;
List<Block> iris = floodInterior(candidate, frameKeys);
List<Block> iris = floodInterior(candidate, frameKeys, inPlane);
if (iris != null && !iris.isEmpty()) {
return ScanResult.ok(new GateStructure(frameBlocks, pickChevrons(frameBlocks, iris), iris));
}
@@ -146,7 +148,7 @@ public class GateStructureScanner {
+ "gate.max-iris-blocks (" + maxIrisBlocks + ").");
}
private List<Block> floodInterior(Block seed, Set<Long> frameKeys) {
private List<Block> floodInterior(Block seed, Set<Long> frameKeys, BlockFace[] inPlane) {
Set<Long> visited = new HashSet<>();
List<Block> interior = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
@@ -158,7 +160,7 @@ public class GateStructureScanner {
interior.add(cur);
if (interior.size() > maxIrisBlocks) return null;
for (BlockFace face : NEIGHBORS) {
for (BlockFace face : inPlane) {
Block next = cur.getRelative(face);
long k = key(next);
if (visited.contains(k) || frameKeys.contains(k)) continue;
@@ -170,6 +172,27 @@ public class GateStructureScanner {
return interior;
}
/** The 4 directions that stay within the ring's own plane, excluding the axis the ring is thinnest along. */
private BlockFace[] inPlaneDirections(List<Block> frameBlocks) {
int minX = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE;
int minY = Integer.MAX_VALUE, maxY = 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());
minY = Math.min(minY, b.getY()); maxY = Math.max(maxY, b.getY());
minZ = Math.min(minZ, b.getZ()); maxZ = Math.max(maxZ, b.getZ());
}
int rangeX = maxX - minX, rangeY = maxY - minY, rangeZ = maxZ - minZ;
if (rangeX <= rangeY && rangeX <= rangeZ) {
return new BlockFace[]{BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH};
} else if (rangeZ <= rangeY && rangeZ <= rangeX) {
return new BlockFace[]{BlockFace.UP, BlockFace.DOWN, BlockFace.EAST, BlockFace.WEST};
} else {
return new BlockFace[]{BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST};
}
}
/**
* 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
+5 -2
View File
@@ -44,8 +44,11 @@ gate:
# 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
# Material poured into the interior (the "event horizon") while the gate is open.
iris-material: WATER
# 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.
iris-open-material: WATER
iris-closed-material: BEDROCK
max-frame-blocks: 300
max-iris-blocks: 200
min-frame-blocks: 8