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
+21 -22
View File
@@ -29,14 +29,11 @@ is the ring's "thickness" and only looks for the interior within that plane, so
doesn't try to flood-fill out through the open air in front of and behind the gate. doesn't try to flood-fill out through the open air in front of and behind the gate.
Chevrons aren't a separate material you place. When the gate is linked, the plugin Chevrons aren't a separate material you place. When the gate is linked, the plugin
flood-fills the ring, walks it in geometric order, and picks out the blocks that stick flood-fills the ring, walks it in geometric order, and picks `gate.chevron-count`
further outward than their immediate ring-neighbours - the "elbow" points of the (default 7) blocks evenly spaced around the ring's actual perimeter. Those blocks
shape, which is where a real Stargate's chevrons sit (an 11-wide octagonal ring like swap to `gate.chevron-lit-material` (default glowstone) while dialing/open, and
the reference design naturally produces 7 of them: the top apex, the four shoulder revert to whatever they looked like at rest when the gate closes - so a chevron can
elbows, and the two side bumps). Those blocks swap to `gate.chevron-lit-material` rest as plain obsidian and blend invisibly into the frame until it lights up.
(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. 1. Build the ring.
2. Optionally place a button directly below where you'll put the sign - this becomes 2. Optionally place a button directly below where you'll put the sign - this becomes
@@ -61,20 +58,22 @@ material, unsealed interior, too far from the sign, etc.
- **Right-click** the sign: cycles the destination shown on line 3 among the other - **Right-click** the sign: cycles the destination shown on line 3 among the other
gates on the same network. gates on the same network.
- **Left-click** the sign: dials the shown destination - chevrons light in sequence, - **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 then the interior becomes the event horizon (`gate.iris-open-material`, default
is just an empty ring you can see straight through) to either the event horizon water) and travel opens up. It auto-closes after `dialing.open-seconds`.
(`gate.iris-open-material`, default water) or, if the iris shield is closed, to - Travel is **one-way**: only the gate you dialed *from* can send you anywhere.
`gate.iris-shield-material` (default iron block) instead, blocking travel even Walking into its event horizon teleports you to the destination, arriving just
though the gate is actively connected. It auto-closes after `dialing.open-seconds`, clear of its iris (never inside it) and still facing whatever direction you were
switching back to the idle material either way. already walking, so entering forward always means exiting forward. The
- Walking into the event horizon teleports you to the connected gate, arriving just destination's horizon is visible but not walkable - stepping into it does nothing,
clear of its iris (never inside it - that would immediately teleport you right back) same as a real Stargate only running one direction at a time.
and still facing whatever direction you were already walking, so entering forward - The **iris shield** is a separate, physical thing from the wormhole connection.
always means exiting forward. **Right-click a button placed directly below the sign** to toggle it. Closed means
- **Right-click a button placed directly below the sign** toggles the iris shield. closed - `gate.iris-shield-material` (default bedrock), solid, blocking everything -
Closing it never needs anything extra. If the gate has a pin set (`/sg pin <code>`), whether or not the gate is even connected right now. With the shield open, the
opening a closed shield prompts you to type the code in chat within interior just reflects the connection state: `gate.iris-idle-material` (default air)
`gate.pin-timeout-seconds` before it'll open. if idle, or the event horizon if actively dialed. Closing needs nothing 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 ## Multi-world
@@ -12,6 +12,7 @@ public class GateConfig {
public final Set<Material> frameMaterials; public final Set<Material> frameMaterials;
public final Material chevronLit; public final Material chevronLit;
public final int chevronCount;
public final Material irisIdleMaterial; public final Material irisIdleMaterial;
public final Material irisOpenMaterial; public final Material irisOpenMaterial;
public final Material irisShieldMaterial; public final Material irisShieldMaterial;
@@ -37,9 +38,10 @@ public class GateConfig {
this.frameMaterials = materials; this.frameMaterials = materials;
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GLOWSTONE, logger); 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.irisIdleMaterial = matOr(cfg.getString("gate.iris-idle-material"), Material.AIR, logger);
this.irisOpenMaterial = matOr(cfg.getString("gate.iris-open-material"), Material.WATER, 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.maxFrameBlocks = cfg.getInt("gate.max-frame-blocks", 300);
this.maxIrisBlocks = cfg.getInt("gate.max-iris-blocks", 200); this.maxIrisBlocks = cfg.getInt("gate.max-iris-blocks", 200);
this.minFrameBlocks = cfg.getInt("gate.min-frame-blocks", 8); this.minFrameBlocks = cfg.getInt("gate.min-frame-blocks", 8);
@@ -43,7 +43,7 @@ public class GateManager {
public void reloadConfig() { public void reloadConfig() {
this.config = new GateConfig(plugin.getConfig(), plugin.getLogger()); 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); config.maxFrameBlocks, config.maxIrisBlocks, config.minFrameBlocks);
} }
@@ -61,18 +61,17 @@ public class GateManager {
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(linked); GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(linked);
if (result.isSuccess()) { if (result.isSuccess()) {
structure = result.structure; 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 { } else {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName() plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName()
+ "' - it may have been damaged (" + result.failureReason + ")"); + "' - 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); RuntimeGate rg = new RuntimeGate(gate, structure);
if (structure != null) applyIrisMaterial(rg);
gatesById.put(gate.getId(), rg); gatesById.put(gate.getId(), rg);
if (gate.getServerId().equals(plugin.getServerId())) { if (gate.getServerId().equals(plugin.getServerId())) {
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg); gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
@@ -178,9 +177,6 @@ public class GateManager {
return LinkResult.fail(result.failureReason); return LinkResult.fail(result.failureReason);
} }
GateStructure structure = result.structure; GateStructure structure = result.structure;
for (Block b : structure.getIris()) {
b.setType(config.irisIdleMaterial);
}
Location exit = computeExitLocation(structure, clickedBlock.getWorld(), facing); Location exit = computeExitLocation(structure, clickedBlock.getWorld(), facing);
Gate gate = new Gate(UUID.randomUUID(), pending.name, pending.network, plugin.getServerId(), 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()); clickedBlock.getX(), clickedBlock.getY(), clickedBlock.getZ());
RuntimeGate rg = new RuntimeGate(gate, structure); RuntimeGate rg = new RuntimeGate(gate, structure);
applyIrisMaterial(rg);
gatesById.put(gate.getId(), rg); gatesById.put(gate.getId(), rg);
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg); gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
storage.saveGate(gate); storage.saveGate(gate);
@@ -262,7 +259,9 @@ public class GateManager {
Runnable finish = () -> { Runnable finish = () -> {
openGate(from, to); openGate(from, to);
from.setOutgoing(true);
openGate(to, from); openGate(to, from);
to.setOutgoing(false);
}; };
if (chevrons.isEmpty()) { if (chevrons.isEmpty()) {
@@ -313,12 +312,11 @@ public class GateManager {
if (gate.getDialTask() != null) { gate.getDialTask().cancel(); gate.setDialTask(null); } if (gate.getDialTask() != null) { gate.getDialTask().cancel(); gate.setDialTask(null); }
if (gate.getCloseTask() != null) { gate.getCloseTask().cancel(); gate.setCloseTask(null); } if (gate.getCloseTask() != null) { gate.getCloseTask().cancel(); gate.setCloseTask(null); }
gate.setOpen(false); gate.setOpen(false);
gate.setOutgoing(false);
RuntimeGate other = gate.getConnectedTo(); RuntimeGate other = gate.getConnectedTo();
gate.setConnectedTo(null); gate.setConnectedTo(null);
if (gate.getStructure() != null) { if (gate.getStructure() != null) {
for (Block b : gate.getStructure().getIris()) { applyIrisMaterial(gate);
b.setType(config.irisIdleMaterial);
}
for (GateStructure.ChevronSlot c : gate.getStructure().getChevrons()) { for (GateStructure.ChevronSlot c : gate.getStructure().getChevrons()) {
c.getBlock().setType(c.getRestingMaterial()); 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) { 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()) { for (Block b : gate.getStructure().getIris()) {
b.setType(mat); b.setType(mat);
if (b.getBlockData() instanceof Levelled lvl) { if (b.getBlockData() instanceof Levelled lvl) {
@@ -348,12 +356,12 @@ public class GateManager {
// ---- Iris shield ---- // ---- 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) { public boolean toggleIrisShield(RuntimeGate rg) {
boolean nowClosed = !rg.getGate().isIrisClosed(); boolean nowClosed = !rg.getGate().isIrisClosed();
rg.getGate().setIrisClosed(nowClosed); rg.getGate().setIrisClosed(nowClosed);
saveGate(rg); saveGate(rg);
if (rg.isOpen() && rg.getStructure() != null) { if (rg.getStructure() != null) {
applyIrisMaterial(rg); applyIrisMaterial(rg);
} }
return nowClosed; return nowClosed;
@@ -363,7 +371,7 @@ public class GateManager {
public void openIrisShield(RuntimeGate rg) { public void openIrisShield(RuntimeGate rg) {
rg.getGate().setIrisClosed(false); rg.getGate().setIrisClosed(false);
saveGate(rg); saveGate(rg);
if (rg.isOpen() && rg.getStructure() != null) { if (rg.getStructure() != null) {
applyIrisMaterial(rg); applyIrisMaterial(rg);
} }
} }
@@ -16,29 +16,14 @@ import java.util.Set;
/** /**
* Flood-fill scanner that discovers a gate's physical structure starting from a punched * 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 * 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" * interior ("iris"), and a fixed number of chevrons evenly spaced around the ring's actual
* points, picked out geometrically, which is where a real Stargate's chevrons sit. * geometric perimeter.
*/ */
public class GateStructureScanner { public class GateStructureScanner {
// Round/octagonal rings built the usual Minecraft way step diagonally at the corners - two private static final BlockFace[] ORTHOGONAL = {
// frame blocks touching only edge-to-edge (or even just corner-to-corner), not face-to-face. BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
// 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][]);
}
public static final class ScanResult { public static final class ScanResult {
public final GateStructure structure; // null on failure public final GateStructure structure; // null on failure
@@ -57,14 +42,16 @@ public class GateStructureScanner {
private final Set<Material> frameMaterials; private final Set<Material> frameMaterials;
private final Material chevronLitMaterial; private final Material chevronLitMaterial;
private final int chevronCount;
private final int maxFrameBlocks; private final int maxFrameBlocks;
private final int maxIrisBlocks; private final int maxIrisBlocks;
private final int minFrameBlocks; 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) { int maxFrameBlocks, int maxIrisBlocks, int minFrameBlocks) {
this.frameMaterials = frameMaterials; this.frameMaterials = frameMaterials;
this.chevronLitMaterial = chevronLitMaterial; this.chevronLitMaterial = chevronLitMaterial;
this.chevronCount = chevronCount;
this.maxFrameBlocks = maxFrameBlocks; this.maxFrameBlocks = maxFrameBlocks;
this.maxIrisBlocks = maxIrisBlocks; this.maxIrisBlocks = maxIrisBlocks;
this.minFrameBlocks = minFrameBlocks; this.minFrameBlocks = minFrameBlocks;
@@ -84,6 +71,16 @@ public class GateStructureScanner {
return ScanResult.fail("That block isn't listed in gate.frame-materials."); 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<>(); Set<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>(); List<Block> frameBlocks = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>(); Deque<Block> queue = new ArrayDeque<>();
@@ -99,7 +96,7 @@ public class GateStructureScanner {
+ "Raise max-frame-blocks or make the frame materials more specific."); + "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]); Block next = cur.getRelative(d[0], d[1], d[2]);
long k = key(next); long k = key(next);
if (frameKeys.contains(k)) continue; if (frameKeys.contains(k)) continue;
@@ -115,12 +112,6 @@ public class GateStructureScanner {
+ minFrameBlocks + " (gate.min-frame-blocks)."); + 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<>(); Set<Long> triedSeeds = new HashSet<>();
boolean triedAnyInterior = false; boolean triedAnyInterior = false;
for (Block frameBlock : frameBlocks) { for (Block frameBlock : frameBlocks) {
@@ -134,7 +125,7 @@ public class GateStructureScanner {
triedAnyInterior = true; triedAnyInterior = true;
List<Block> iris = floodInterior(candidate, frameKeys, inPlane); List<Block> iris = floodInterior(candidate, frameKeys, inPlane);
if (iris != null && !iris.isEmpty()) { 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 + ")."); + "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) { private List<Block> floodInterior(Block seed, Set<Long> frameKeys, BlockFace[] inPlane) {
Set<Long> visited = new HashSet<>(); Set<Long> visited = new HashSet<>();
List<Block> interior = new ArrayList<>(); 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 * Picks {@code chevronCount} chevrons evenly spaced around the ring's actual geometric
* chevrons sit - not evenly spaced by scan order. Works by walking the ring in geometric * perimeter - walks the ring in order (each frame block should have exactly two ring
* order (each frame block should have exactly two ring neighbours) and marking local maxima * neighbours) and takes evenly-spaced indices from that walk, not raw scan-discovery order.
* of distance from the interior's centroid, i.e. points that stick out further than their
* immediate neighbours on the ring.
*/ */
private List<GateStructure.ChevronSlot> pickChevrons(List<Block> frameBlocks, List<Block> iris) { private List<GateStructure.ChevronSlot> pickChevrons(List<Block> frameBlocks, BlockFace[] inPlane) {
// The ring is essentially 2D (one block thick); find which world axis is the thin one. boolean horizontalIsX = false;
int minX = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE; for (BlockFace f : inPlane) if (f.getModX() != 0) horizontalIsX = true;
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);
Map<Long, Block> plane = new HashMap<>(); Map<Long, Block> plane = new HashMap<>();
Map<Block, int[]> coords = new HashMap<>();
for (Block b : frameBlocks) { for (Block b : frameBlocks) {
int col = horizontalIsX ? b.getX() : b.getZ(); int col = horizontalIsX ? b.getX() : b.getZ();
int row = b.getY(); int row = b.getY();
long k = (((long) row) << 32) ^ (col & 0xFFFFFFFFL); long k = (((long) row) << 32) ^ (col & 0xFFFFFFFFL);
plane.putIfAbsent(k, b); plane.putIfAbsent(k, b);
coords.put(b, new int[]{row, col});
} }
List<Block> ordered = walkRing(frameBlocks, plane, horizontalIsX); List<Block> ordered = walkRing(frameBlocks, plane, horizontalIsX);
if (ordered == null || ordered.size() < 4) { List<Block> ring = (ordered != null && ordered.size() >= 4) ? ordered : frameBlocks;
return fallbackEvenlySpaced(frameBlocks);
}
double centroidRow, centroidCol; int count = Math.max(0, Math.min(chevronCount, ring.size()));
if (!iris.isEmpty()) { List<GateStructure.ChevronSlot> slots = new ArrayList<>(count);
long sumRow = 0, sumCol = 0; if (count == 0) return slots;
for (Block b : iris) { double step = ring.size() / (double) count;
sumRow += b.getY(); Set<Integer> chosen = new HashSet<>();
sumCol += horizontalIsX ? b.getX() : b.getZ(); 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()));
} }
centroidRow = (double) sumRow / iris.size(); return slots;
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 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;
} }
private static final int[][] PLANE_8_OFFSETS = { private static final int[][] PLANE_8_OFFSETS = {
@@ -302,18 +303,6 @@ public class GateStructureScanner {
return ordered.size() == frameBlocks.size() ? ordered : null; 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) { private long key(Block b) {
return (((long) b.getX() & 0x3FFFFFF) << 38) | (((long) (b.getY() + 512) & 0xFFF) << 26) | ((long) b.getZ() & 0x3FFFFFF); 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 final Gate gate;
private GateStructure structure; // null if structure could not be (re)scanned private GateStructure structure; // null if structure could not be (re)scanned
private boolean open = false; 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 RuntimeGate connectedTo = null;
private BukkitTask closeTask; private BukkitTask closeTask;
private BukkitTask dialTask; private BukkitTask dialTask;
@@ -24,6 +25,8 @@ public class RuntimeGate {
public void setStructure(GateStructure structure) { this.structure = structure; } public void setStructure(GateStructure structure) { this.structure = structure; }
public boolean isOpen() { return open; } public boolean isOpen() { return open; }
public void setOpen(boolean open) { this.open = 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 RuntimeGate getConnectedTo() { return connectedTo; }
public void setConnectedTo(RuntimeGate connectedTo) { this.connectedTo = connectedTo; } public void setConnectedTo(RuntimeGate connectedTo) { this.connectedTo = connectedTo; }
public BukkitTask getCloseTask() { return closeTask; } public BukkitTask getCloseTask() { return closeTask; }
@@ -18,10 +18,12 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
* Walking into an open gate's event horizon teleports the player to the connected gate, * Walking into the DIALING gate's event horizon teleports the player to the connected gate,
* arriving just past the destination's iris (never inside it - that would immediately * arriving just past its iris (never inside it) and facing the same absolute direction they
* re-trigger this same listener and bounce them straight back) and facing the same absolute * were already walking, so "enter forward" always means "exit forward". Travel is strictly
* direction they were already walking, so "enter forward" always means "exit forward". * 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 { public class GateTeleportListener implements Listener {
@@ -51,7 +53,7 @@ public class GateTeleportListener implements Listener {
Block standing = to.getBlock(); Block standing = to.getBlock();
List<RuntimeGate> gates = gateManager.all(); List<RuntimeGate> gates = gateManager.all();
for (RuntimeGate rg : gates) { 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()) { for (Block iris : rg.getStructure().getIris()) {
if (iris.getX() == standing.getX() && iris.getY() == standing.getY() && iris.getZ() == standing.getZ() if (iris.getX() == standing.getX() && iris.getY() == standing.getY() && iris.getZ() == standing.getZ()
&& iris.getWorld().equals(standing.getWorld())) { && 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 # 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 # 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 # 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: frame-materials:
- OBSIDIAN - OBSIDIAN
- GOLD_BLOCK - GOLD_BLOCK
@@ -39,19 +42,22 @@ gate:
- BIRCH_PLANKS - BIRCH_PLANKS
- OAK_PLANKS - OAK_PLANKS
# Chevrons aren't a separate material you place. The plugin flood-fills the ring, # 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 # walks it in geometric order, and picks this many blocks, evenly spaced around the
# than their ring-neighbours - the "elbow" points of the shape, same as a real # ring's actual perimeter (not raw scan order) - then swaps just those to
# Stargate's chevron placement - then swaps just those to chevron-lit-material while # chevron-lit-material while dialing/open, restoring whatever they looked like at
# dialing/open, restoring whatever they looked like at rest when the gate closes. # rest when the gate closes.
chevron-lit-material: GLOWSTONE chevron-lit-material: GLOWSTONE
# The interior has three looks, matching SG-1: idle (not connected) is just empty chevron-count: 7
# air - you can see straight through the ring. Connected, it's the event horizon # The interior's look is driven by two independent things, matching SG-1:
# (iris-open-material) UNLESS the iris shield is closed, in which case it shows # - The iris shield: a physical barrier toggled with a button placed directly below
# iris-shield-material instead and blocks all travel even though the gate is active. # the control sign. Closed means closed - iris-shield-material, solid, blocking
# The shield is toggled with a button placed directly below the control sign. # 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-idle-material: AIR
iris-open-material: WATER iris-open-material: WATER
iris-shield-material: IRON_BLOCK iris-shield-material: BEDROCK
max-frame-blocks: 300 max-frame-blocks: 300
max-iris-blocks: 200 max-iris-blocks: 200
min-frame-blocks: 8 min-frame-blocks: 8