Revert to flexible flood-fill scanning with geometric chevron placement

The exact-template approach from the last commit over-fit to the
sample ASCII diagram, which was meant to teach the target shape, not
be matched literally cell-for-cell. Back to flood-filling any
connected, fully-enclosed ring the builder makes (any size/shape), but
chevrons are no longer evenly spaced by scan order - the scanner walks
the ring in geometric order and marks local maxima of distance from
the interior's centroid, i.e. the blocks that stick out further than
their ring-neighbours. That's where a real Stargate's chevrons sit
(the 11-wide reference ring naturally produces 7 this way: the top
apex, four shoulder elbows, two side bumps), without hardcoding a
specific size.

Falls back to the old evenly-spaced picker only if the ring isn't a
simple single-thickness loop (each frame block should have exactly
two in-plane neighbours) so odd/thick builds still work.

gate.max-frame-blocks/max-iris-blocks/min-frame-blocks are back;
gate.template is gone.
This commit is contained in:
Michael Burgess
2026-08-09 10:57:00 -04:00
parent ed8415a497
commit fe3b2b0f14
7 changed files with 316 additions and 273 deletions
@@ -4,17 +4,18 @@ import org.bukkit.Material;
import org.bukkit.configuration.file.FileConfiguration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;
/** Typed view over the `gate:` / `dialing:` / `network:` sections of config.yml. */
public class GateConfig {
public final GateTemplate template;
public final Set<Material> frameMaterials;
public final Material chevronLit;
public final Material irisMaterial;
public final int maxFrameBlocks;
public final int maxIrisBlocks;
public final int minFrameBlocks;
public final int openSeconds;
public final int chevronTickDelay;
public final boolean playSounds;
@@ -23,13 +24,6 @@ public class GateConfig {
public final int maxLinkDistance;
public GateConfig(FileConfiguration cfg, Logger logger) {
List<String> templateRows = cfg.getStringList("gate.template");
if (templateRows.isEmpty()) {
logger.warning("[Stargate] gate.template is empty in config.yml - falling back to a built-in default.");
templateRows = DEFAULT_TEMPLATE;
}
this.template = new GateTemplate(templateRows);
Set<Material> materials = new HashSet<>();
for (String s : cfg.getStringList("gate.frame-materials")) {
Material m = Material.matchMaterial(s);
@@ -41,6 +35,9 @@ public class GateConfig {
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.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);
this.openSeconds = cfg.getInt("dialing.open-seconds", 10);
this.chevronTickDelay = cfg.getInt("dialing.chevron-tick-delay", 4);
this.playSounds = cfg.getBoolean("dialing.play-sounds", true);
@@ -58,18 +55,4 @@ public class GateConfig {
}
return m;
}
private static final List<String> DEFAULT_TEMPLATE = List.of(
"00000000000",
"0000*1*0000",
"00012221000",
"00*22222*00",
"0*2222222*0",
"01222222210",
"0*2222222*0",
"00*22222*00",
"00012221000",
"0000***0000",
"00000000000"
);
}
@@ -26,7 +26,7 @@ public class GateManager {
private final StargatePlugin plugin;
private final GateStorage storage;
private GateConfig config;
private GateTemplateScanner scanner;
private GateStructureScanner scanner;
private final Map<UUID, RuntimeGate> gatesById = new HashMap<>();
private final Map<String, RuntimeGate> gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate
@@ -39,7 +39,8 @@ public class GateManager {
public void reloadConfig() {
this.config = new GateConfig(plugin.getConfig(), plugin.getLogger());
this.scanner = new GateTemplateScanner(config.template, config.frameMaterials);
this.scanner = new GateStructureScanner(config.frameMaterials, config.chevronLit,
config.maxFrameBlocks, config.maxIrisBlocks, config.minFrameBlocks);
}
public GateConfig getConfig() { return config; }
@@ -53,8 +54,7 @@ public class GateManager {
World world = Bukkit.getWorld(gate.getWorld());
if (world != null) {
Block linked = world.getBlockAt(gate.getLinkX(), gate.getLinkY(), gate.getLinkZ());
BlockFace facing = parseFacing(gate.getFacing());
GateTemplateScanner.ScanResult result = scanner.scan(linked, facing);
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(linked);
if (result.isSuccess()) {
structure = result.structure;
} else {
@@ -164,7 +164,7 @@ public class GateManager {
}
BlockFace facing = yawToCardinal(player.getLocation().getYaw());
GateTemplateScanner.ScanResult result = scanner.scan(clickedBlock, facing);
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(clickedBlock);
if (!result.isSuccess()) {
return LinkResult.fail(result.failureReason);
}
@@ -238,17 +238,6 @@ public class GateManager {
};
}
private BlockFace parseFacing(String facing) {
try {
BlockFace face = BlockFace.valueOf(facing);
if (face == BlockFace.NORTH || face == BlockFace.SOUTH || face == BlockFace.EAST || face == BlockFace.WEST) {
return face;
}
} catch (Exception ignored) {
}
return BlockFace.NORTH;
}
// ---- Dialing ----
/** Starts the chevron-lighting animation, then opens the gate and connects it to the destination. */
@@ -0,0 +1,274 @@
package dev.skywalker3200.stargate.paper.gate;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
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.
*/
public class GateStructureScanner {
private static final BlockFace[] NEIGHBORS = {
BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
};
public static final class ScanResult {
public final GateStructure structure; // null on failure
public final String failureReason; // null on success
private ScanResult(GateStructure structure, String failureReason) {
this.structure = structure;
this.failureReason = failureReason;
}
static ScanResult ok(GateStructure s) { return new ScanResult(s, null); }
static ScanResult fail(String reason) { return new ScanResult(null, reason); }
public boolean isSuccess() { return structure != null; }
}
private final Set<Material> frameMaterials;
private final Material chevronLitMaterial;
private final int maxFrameBlocks;
private final int maxIrisBlocks;
private final int minFrameBlocks;
public GateStructureScanner(Set<Material> frameMaterials, Material chevronLitMaterial,
int maxFrameBlocks, int maxIrisBlocks, int minFrameBlocks) {
this.frameMaterials = frameMaterials;
this.chevronLitMaterial = chevronLitMaterial;
this.maxFrameBlocks = maxFrameBlocks;
this.maxIrisBlocks = maxIrisBlocks;
this.minFrameBlocks = minFrameBlocks;
}
private boolean isFrameMaterial(Material m) {
// a gate re-scanned mid-dial (e.g. server restart) may have chevrons still lit
return frameMaterials.contains(m) || m == chevronLitMaterial;
}
public GateStructure scan(Block seed) {
return scanWithDiagnostics(seed).structure;
}
public ScanResult scanWithDiagnostics(Block seed) {
if (!isFrameMaterial(seed.getType())) {
return ScanResult.fail("That block isn't listed in gate.frame-materials.");
}
Set<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
queue.add(seed);
frameKeys.add(key(seed));
while (!queue.isEmpty()) {
Block cur = queue.poll();
frameBlocks.add(cur);
if (frameBlocks.size() > maxFrameBlocks) {
return ScanResult.fail("The connected frame has more than gate.max-frame-blocks (" + maxFrameBlocks
+ ") blocks. Either it's too big, or a frame material is leaking into a much larger structure. "
+ "Raise max-frame-blocks or make the frame materials more specific.");
}
for (BlockFace face : NEIGHBORS) {
Block next = cur.getRelative(face);
long k = key(next);
if (frameKeys.contains(k)) continue;
if (isFrameMaterial(next.getType())) {
frameKeys.add(k);
queue.add(next);
}
}
}
if (frameBlocks.size() < minFrameBlocks) {
return ScanResult.fail("Only found " + frameBlocks.size() + " connected frame block(s), need at least "
+ minFrameBlocks + " (gate.min-frame-blocks).");
}
Set<Long> triedSeeds = new HashSet<>();
boolean triedAnyInterior = false;
for (Block frameBlock : frameBlocks) {
for (BlockFace face : NEIGHBORS) {
Block candidate = frameBlock.getRelative(face);
long ck = key(candidate);
if (frameKeys.contains(ck) || triedSeeds.contains(ck)) continue;
triedSeeds.add(ck);
if (candidate.getType().isSolid()) continue;
triedAnyInterior = true;
List<Block> iris = floodInterior(candidate, frameKeys);
if (iris != null && !iris.isEmpty()) {
return ScanResult.ok(new GateStructure(frameBlocks, pickChevrons(frameBlocks, iris), iris));
}
}
}
if (!triedAnyInterior) {
return ScanResult.fail("The frame has no open interior at all (every block touching the ring is solid). "
+ "Leave the middle hollow.");
}
return ScanResult.fail("Found a " + frameBlocks.size() + "-block frame, but its interior isn't fully "
+ "enclosed - the empty space either leaks out through a gap in the ring, or is bigger than "
+ "gate.max-iris-blocks (" + maxIrisBlocks + ").");
}
private List<Block> floodInterior(Block seed, Set<Long> frameKeys) {
Set<Long> visited = new HashSet<>();
List<Block> interior = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
queue.add(seed);
visited.add(key(seed));
while (!queue.isEmpty()) {
Block cur = queue.poll();
interior.add(cur);
if (interior.size() > maxIrisBlocks) return null;
for (BlockFace face : NEIGHBORS) {
Block next = cur.getRelative(face);
long k = key(next);
if (visited.contains(k) || frameKeys.contains(k)) continue;
visited.add(k);
if (next.getType().isSolid()) continue; // ground/ceiling/wall boundary, not part of the interior
queue.add(next);
}
}
return interior;
}
/**
* 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.
*/
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);
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);
}
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 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;
}
/** Walks the ring in geometric order assuming each frame block has exactly two in-plane neighbours; null if that assumption fails. */
private List<Block> walkRing(List<Block> frameBlocks, Map<Long, Block> plane, boolean horizontalIsX) {
Map<Block, List<Block>> adjacency = new HashMap<>();
for (Block b : frameBlocks) {
int col = horizontalIsX ? b.getX() : b.getZ();
int row = b.getY();
List<Block> neighbors = new ArrayList<>();
for (int[] d : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) {
long k = (((long) (row + d[0])) << 32) ^ ((col + d[1]) & 0xFFFFFFFFL);
Block n = plane.get(k);
if (n != null && n != b) neighbors.add(n);
}
if (neighbors.size() != 2) return null; // not a simple single-thickness loop; bail out to fallback
adjacency.put(b, neighbors);
}
Block start = frameBlocks.get(0);
List<Block> ordered = new ArrayList<>();
Block prev = null;
Block current = start;
int guard = frameBlocks.size() + 2;
while (guard-- > 0) {
ordered.add(current);
List<Block> neighbors = adjacency.get(current);
Block next = neighbors.get(0).equals(prev) ? neighbors.get(1) : neighbors.get(0);
prev = current;
current = next;
if (current.equals(start)) break;
}
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);
}
}
@@ -1,64 +0,0 @@
package dev.skywalker3200.stargate.paper.gate;
import java.util.ArrayList;
import java.util.List;
/**
* A fixed 2D blueprint for a gate, read top-to-bottom / left-to-right, e.g.:
* <pre>
* 0000*1*0000
* 00012221000
* 00*22222*00
* ...
* </pre>
* '*' = frame, '1' = chevron (a frame block the plugin lights up while dialing), '2' = interior
* ("iris") filled while open, '0' = ignored - not part of the structure at all.
*/
public class GateTemplate {
public static final char FRAME = '*';
public static final char CHEVRON = '1';
public static final char IRIS = '2';
public static final char IGNORE = '0';
private final char[][] grid; // [row][col]
private final int height;
private final int width;
public GateTemplate(List<String> rows) {
this.height = rows.size();
int w = 0;
for (String row : rows) w = Math.max(w, row.length());
this.width = w;
this.grid = new char[height][width];
for (int r = 0; r < height; r++) {
String row = rows.get(r);
for (int c = 0; c < width; c++) {
grid[r][c] = c < row.length() ? row.charAt(c) : IGNORE;
}
}
}
public int getHeight() { return height; }
public int getWidth() { return width; }
public char symbolAt(int row, int col) {
if (row < 0 || row >= height || col < 0 || col >= width) return IGNORE;
return grid[row][col];
}
public boolean isFrameSymbol(char c) {
return c == FRAME || c == CHEVRON;
}
/** All (row, col) cells matching the given symbol, in row-major order. */
public List<int[]> cellsOf(char symbol) {
List<int[]> cells = new ArrayList<>();
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++) {
if (grid[r][c] == symbol) cells.add(new int[]{r, c});
}
}
return cells;
}
}
@@ -1,112 +0,0 @@
package dev.skywalker3200.stargate.paper.gate;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Matches a {@link GateTemplate} against the world: given a block the player punched and the
* horizontal direction they were facing, tries every plausible way the punched block could be
* one of the template's frame/chevron cells, and checks the rest of the template lines up.
*
* The template is built in a vertical plane facing the player - columns run along the world's
* X axis if the player faced north/south, or along Z if they faced east/west. Row 0 is the top
* of the gate; increasing row means decreasing Y.
*/
public class GateTemplateScanner {
private final GateTemplate template;
private final Set<Material> frameMaterials;
public GateTemplateScanner(GateTemplate template, Set<Material> frameMaterials) {
this.template = template;
this.frameMaterials = frameMaterials;
}
public static final class ScanResult {
public final GateStructure structure; // null on failure
public final String failureReason; // null on success
private ScanResult(GateStructure structure, String failureReason) {
this.structure = structure;
this.failureReason = failureReason;
}
static ScanResult ok(GateStructure s) { return new ScanResult(s, null); }
static ScanResult fail(String reason) { return new ScanResult(null, reason); }
public boolean isSuccess() { return structure != null; }
}
/** True for NORTH/SOUTH, false for EAST/WEST; anything else gets snapped to the nearer of the two axes. */
private boolean columnsRunAlongX(BlockFace facing) {
return facing == BlockFace.NORTH || facing == BlockFace.SOUTH;
}
public ScanResult scan(Block punchedBlock, BlockFace facing) {
if (!frameMaterials.contains(punchedBlock.getType())) {
return ScanResult.fail("That block isn't listed in gate.frame-materials.");
}
boolean alongX = columnsRunAlongX(facing);
List<int[]> candidates = new ArrayList<>();
candidates.addAll(template.cellsOf(GateTemplate.FRAME));
candidates.addAll(template.cellsOf(GateTemplate.CHEVRON));
for (int[] anchor : candidates) {
int anchorRow = anchor[0];
int anchorCol = anchor[1];
List<Block> frame = new ArrayList<>();
List<GateStructure.ChevronSlot> chevrons = new ArrayList<>();
List<Block> iris = new ArrayList<>();
boolean matched = true;
outer:
for (int r = 0; r < template.getHeight() && matched; r++) {
for (int c = 0; c < template.getWidth(); c++) {
char symbol = template.symbolAt(r, c);
if (symbol == GateTemplate.IGNORE) continue;
Block world = worldBlockFor(punchedBlock, alongX, anchorRow, anchorCol, r, c);
if (template.isFrameSymbol(symbol)) {
if (!frameMaterials.contains(world.getType())) {
matched = false;
break outer;
}
frame.add(world);
if (symbol == GateTemplate.CHEVRON) {
chevrons.add(new GateStructure.ChevronSlot(world, world.getType()));
}
} else if (symbol == GateTemplate.IRIS) {
iris.add(world);
}
}
}
if (matched) {
return ScanResult.ok(new GateStructure(frame, chevrons, iris));
}
}
return ScanResult.fail("Found a frame block, but no orientation of the configured template lines up "
+ "starting there. Make sure you're facing the gate square-on (not at an angle) when you punch it, "
+ "and that the build matches gate.template exactly.");
}
private Block worldBlockFor(Block punchedBlock, boolean alongX, int anchorRow, int anchorCol, int r, int c) {
int dy = anchorRow - r; // row increases downward, Y decreases as row increases
int dCol = c - anchorCol;
if (alongX) {
return punchedBlock.getRelative(dCol, dy, 0);
} else {
return punchedBlock.getRelative(0, dy, dCol);
}
}
}
+14 -23
View File
@@ -26,38 +26,29 @@ cross-server:
enabled: false
gate:
# The exact shape of a gate, read top row to bottom row. This is matched precisely
# against the world - no flood-fill guessing. Symbols:
# * = frame block (never touched by the plugin)
# 1 = chevron - a frame block that lights up to chevron-lit-material while
# dialing/open, then reverts to whatever it looked like at rest when closed
# 2 = interior ("iris") - filled with iris-material while open, air while closed
# 0 = ignored - not part of the gate at all
# Must be built in a single vertical plane, one block thick, aligned to the world's
# X or Z axis (i.e. facing north/south/east/west, not built at an angle).
template:
- "00000000000"
- "0000*1*0000"
- "00012221000"
- "00*22222*00"
- "0*2222222*0"
- "01222222210"
- "0*2222222*0"
- "00*22222*00"
- "00012221000"
- "0000***0000"
- "00000000000"
# Any of these materials satisfy a '*' or '1' cell above. Mix and match to match your
# build (the default matches an obsidian/gilded-blackstone ring).
# Any of these materials count as the ring's frame (and count as a valid chevron
# material too, since chevrons are just frame blocks the plugin temporarily swaps).
# 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.
frame-materials:
- OBSIDIAN
- GOLD_BLOCK
- GILDED_BLACKSTONE
- 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.
chevron-lit-material: GILDED_BLACKSTONE
# Material poured into the interior (the "event horizon") while the gate is open.
iris-material: WATER
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.