Replace flood-fill scanner with an exact gate template

Instead of guessing a gate's shape by flood-filling connected frame
material, the plugin now matches a fixed ASCII template against the
world (gate.template in config.yml), the same idea as the classic
Stargate plugin's .gate files. Default is an 11x11 ring with 7
chevrons at their real positions rather than auto-spaced.

- New GateTemplate parses the '*'/'1'/'2'/'0' grid.
- New GateTemplateScanner tries every plausible alignment of a
  punched block against a frame/chevron cell, then verifies the rest
  of the template matches; no more flood-fill leak/enclosure
  diagnostics needed since the shape is exact.
- The player's look direction at punch time (snapped to N/S/E/W)
  decides whether template columns run along world X or Z; this
  facing is now stored in Gate.facing (repurposing the old
  sign-attachment field) so restarts can re-scan deterministically.
- Exit-teleport yaw now comes from that stored facing instead of a
  geometric guess.
- Removed gate.chevron-count/max-frame-blocks/max-iris-blocks/
  min-frame-blocks - the template itself defines size and chevrons.
This commit is contained in:
Michael Burgess
2026-08-09 10:48:47 -04:00
parent 317602c00e
commit ed8415a497
7 changed files with 310 additions and 266 deletions
+39 -16
View File
@@ -16,29 +16,52 @@ Build everything with `./gradlew build`. Jars land in each module's `build/libs/
## Building a gate ## Building a gate
1. Build a fully-enclosed ring out of any block(s) in `gate.frame-materials` (default: A gate's shape is a fixed template in `config.yml` (`gate.template`), matched exactly
obsidian, gold block, gilded blackstone, birch/oak planks). Leave the middle hollow. against the world - not flood-filled or guessed. The default is an 11x11 ring with 7
No gaps - the ring must be a sealed loop. chevrons, laid out top row to bottom row:
2. Nothing else to build for chevrons. On creation, the plugin automatically picks
`gate.chevron-count` (default 6) frame blocks, evenly spaced around the ring, and ```
remembers what they looked like at rest. While dialing/open those blocks swap to 00000000000
`gate.chevron-lit-material` (default gilded blackstone); closing the gate restores 0000*1*0000
them to their original block, so a chevron can rest as plain obsidian and blend 00012221000
invisibly into the frame until it lights up. 00*22222*00
3. Place a sign anywhere - it doesn't need to touch the frame, so it can act like a DHD 0*2222222*0
01222222210
0*2222222*0
00*22222*00
00012221000
0000***0000
00000000000
```
- `*` — frame block, never touched by the plugin. Must be one of `gate.frame-materials`
(default: obsidian, gold block, gilded blackstone, birch/oak planks).
- `1` — a chevron: a frame block (same material rules as `*`) that swaps to
`gate.chevron-lit-material` (default gilded blackstone) while dialing/open, then
reverts to whatever it 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.
- `2` — interior ("iris"), filled with `gate.iris-material` (default water) while open.
- `0` — ignored, not part of the gate.
Build it in a single vertical plane, one block thick, aligned to the world's X or Z
axis (facing north/south/east/west - not built at an angle). Edit `gate.template` in
`config.yml` if you want a different size or chevron count/placement.
1. Build the frame to match the template.
2. Place a sign anywhere - it doesn't need to touch the frame, so it can act like a DHD
console standing apart from the gate - with: console standing apart from the gate - with:
- Line 1: `[Stargate]` - Line 1: `[Stargate]`
- Line 2: network name (blank = default network) - Line 2: network name (blank = default network)
- Line 3: gate name (blank = auto-generated) - Line 3: gate name (blank = auto-generated)
- Line 4: `hidden` to keep it out of the cycle list, `fixed:GateName` to lock this - Line 4: `hidden` to keep it out of the cycle list, `fixed:GateName` to lock this
gate to always dial `GateName` (no right-click cycling), or blank gate to always dial `GateName` (no right-click cycling), or blank
4. The sign will show "Punch the gate (30s)". Within `gate.link-timeout-seconds`, 3. The sign will show "Punch the gate (30s)". Within `gate.link-timeout-seconds`, stand
left-click (punch) any block of the frame ring, within `gate.max-link-distance` of facing the gate square-on and left-click (punch) any `*` or `1` block, within
the sign. The plugin flood-fills out from that block, confirms it's a fully enclosed `gate.max-link-distance` of the sign. The plugin matches the template against the
ring, and links the sign to it. world starting from that block and links the sign to it.
If punching a block doesn't work, the plugin tells you exactly why in chat - an If punching a block doesn't work, the plugin tells you exactly why in chat - wrong
unrecognized frame material, a gap in the ring, too far from the sign, etc. material, no orientation of the template lines up, too far from the sign, etc.
## Using a gate ## Using a gate
@@ -4,19 +4,17 @@ import org.bukkit.Material;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.logging.Logger; import java.util.logging.Logger;
/** Typed view over the `gate:` / `dialing:` / `network:` sections of config.yml. */ /** Typed view over the `gate:` / `dialing:` / `network:` sections of config.yml. */
public class GateConfig { public class GateConfig {
public final GateTemplate template;
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 irisMaterial; public final Material irisMaterial;
public final int maxFrameBlocks;
public final int maxIrisBlocks;
public final int minFrameBlocks;
public final int openSeconds; public final int openSeconds;
public final int chevronTickDelay; public final int chevronTickDelay;
public final boolean playSounds; public final boolean playSounds;
@@ -25,6 +23,13 @@ public class GateConfig {
public final int maxLinkDistance; public final int maxLinkDistance;
public GateConfig(FileConfiguration cfg, Logger logger) { 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<>(); Set<Material> materials = new HashSet<>();
for (String s : cfg.getStringList("gate.frame-materials")) { for (String s : cfg.getStringList("gate.frame-materials")) {
Material m = Material.matchMaterial(s); Material m = Material.matchMaterial(s);
@@ -35,11 +40,7 @@ public class GateConfig {
this.frameMaterials = materials; this.frameMaterials = materials;
this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GILDED_BLACKSTONE, logger); this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GILDED_BLACKSTONE, logger);
this.chevronCount = cfg.getInt("gate.chevron-count", 6);
this.irisMaterial = matOr(cfg.getString("gate.iris-material"), Material.WATER, 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.openSeconds = cfg.getInt("dialing.open-seconds", 10);
this.chevronTickDelay = cfg.getInt("dialing.chevron-tick-delay", 4); this.chevronTickDelay = cfg.getInt("dialing.chevron-tick-delay", 4);
this.playSounds = cfg.getBoolean("dialing.play-sounds", true); this.playSounds = cfg.getBoolean("dialing.play-sounds", true);
@@ -57,4 +58,18 @@ public class GateConfig {
} }
return m; 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"
);
} }
@@ -8,6 +8,7 @@ import org.bukkit.Location;
import org.bukkit.Sound; import org.bukkit.Sound;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Levelled; import org.bukkit.block.data.Levelled;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -25,7 +26,7 @@ public class GateManager {
private final StargatePlugin plugin; private final StargatePlugin plugin;
private final GateStorage storage; private final GateStorage storage;
private GateConfig config; private GateConfig config;
private GateStructureScanner scanner; private GateTemplateScanner scanner;
private final Map<UUID, RuntimeGate> gatesById = new HashMap<>(); private final Map<UUID, RuntimeGate> gatesById = new HashMap<>();
private final Map<String, RuntimeGate> gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate private final Map<String, RuntimeGate> gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate
@@ -38,8 +39,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, config.chevronCount, this.scanner = new GateTemplateScanner(config.template, config.frameMaterials);
config.maxFrameBlocks, config.maxIrisBlocks, config.minFrameBlocks);
} }
public GateConfig getConfig() { return config; } public GateConfig getConfig() { return config; }
@@ -53,9 +53,13 @@ public class GateManager {
World world = Bukkit.getWorld(gate.getWorld()); World world = Bukkit.getWorld(gate.getWorld());
if (world != null) { if (world != null) {
Block linked = world.getBlockAt(gate.getLinkX(), gate.getLinkY(), gate.getLinkZ()); Block linked = world.getBlockAt(gate.getLinkX(), gate.getLinkY(), gate.getLinkZ());
structure = scanner.scan(linked); BlockFace facing = parseFacing(gate.getFacing());
if (structure == null) { GateTemplateScanner.ScanResult result = scanner.scan(linked, facing);
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName() + "' - it may have been damaged."); if (result.isSuccess()) {
structure = result.structure;
} else {
plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName()
+ "' - it may have been damaged (" + result.failureReason + ")");
} }
} }
} }
@@ -159,17 +163,18 @@ public class GateManager {
+ config.maxLinkDistance + "."); + config.maxLinkDistance + ".");
} }
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(clickedBlock); BlockFace facing = yawToCardinal(player.getLocation().getYaw());
GateTemplateScanner.ScanResult result = scanner.scan(clickedBlock, facing);
if (!result.isSuccess()) { if (!result.isSuccess()) {
return LinkResult.fail(result.failureReason); return LinkResult.fail(result.failureReason);
} }
GateStructure structure = result.structure; GateStructure structure = result.structure;
Location exit = computeExitLocation(structure, clickedBlock); 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(),
exit.getWorld().getName(), exit.getBlockX(), exit.getBlockY(), exit.getBlockZ(), exit.getYaw(), exit.getWorld().getName(), exit.getBlockX(), exit.getBlockY(), exit.getBlockZ(), exit.getYaw(),
signRef.getX(), signRef.getY(), signRef.getZ(), signRef.getWorld().getName(), signRef.getX(), signRef.getY(), signRef.getZ(), signRef.getWorld().getName(),
signBlockFacing(signRef), pending.owner, pending.flags, pending.fixedDestination, facing.name(), pending.owner, pending.flags, pending.fixedDestination,
clickedBlock.getX(), clickedBlock.getY(), clickedBlock.getZ()); clickedBlock.getX(), clickedBlock.getY(), clickedBlock.getZ());
RuntimeGate rg = new RuntimeGate(gate, structure); RuntimeGate rg = new RuntimeGate(gate, structure);
@@ -192,12 +197,10 @@ public class GateManager {
storage.saveGate(rg.getGate()); storage.saveGate(rg.getGate());
} }
/** frameBlock is whichever block the player punched to link the gate - used only to pick a facing. */ private Location computeExitLocation(GateStructure structure, World world, BlockFace facing) {
private Location computeExitLocation(GateStructure structure, Block frameBlock) {
List<Block> iris = structure.getIris(); List<Block> iris = structure.getIris();
World world = frameBlock.getWorld();
if (iris.isEmpty()) { if (iris.isEmpty()) {
return frameBlock.getLocation(); return new Location(world, 0, 0, 0);
} }
long sumX = 0, sumZ = 0; long sumX = 0, sumZ = 0;
int minY = Integer.MAX_VALUE; int minY = Integer.MAX_VALUE;
@@ -208,23 +211,42 @@ public class GateManager {
} }
double avgX = (double) sumX / iris.size() + 0.5; double avgX = (double) sumX / iris.size() + 0.5;
double avgZ = (double) sumZ / iris.size() + 0.5; double avgZ = (double) sumZ / iris.size() + 0.5;
// A player arriving through this gate keeps walking the same direction the linker
// Face away from the punched frame block, out through the opposite side of the iris - // faced when they punched it - i.e. "into" the gate, out the far side.
// a reasonable guess at "outward" without relying on a sign's wall orientation. return new Location(world, avgX, minY + 1, avgZ, faceToYaw(facing), 0f);
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) { private float faceToYaw(BlockFace face) {
org.bukkit.block.BlockState state = signBlock.getState(); return switch (face) {
if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) { case NORTH -> 180f;
return wallSign.getFacing().name(); case SOUTH -> 0f;
case EAST -> -90f;
case WEST -> 90f;
default -> 0f;
};
} }
return "SELF";
/** Snaps a look yaw to the nearest of the four cardinal directions. */
private BlockFace yawToCardinal(float yaw) {
float y = yaw < 0 ? yaw + 360f : yaw;
int i = Math.round(y / 90f) & 3;
return switch (i) {
case 0 -> BlockFace.SOUTH;
case 1 -> BlockFace.WEST;
case 2 -> BlockFace.NORTH;
default -> BlockFace.EAST;
};
}
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 ---- // ---- Dialing ----
@@ -1,204 +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.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Flood-fill scanner that discovers a gate's physical structure starting from the block
* a control sign is attached to: the frame ring, N evenly-spaced chevron slots picked out
* of that ring, and the enclosed interior ("iris") that gets filled with water while the
* gate is open.
*
* Chevrons are not identified by a special "unlit" material - they're ordinary frame blocks
* that the plugin temporarily swaps to {@code chevronLitMaterial} while dialing/open, then
* restores to whatever they originally looked like. That way a chevron can rest as plain
* obsidian, indistinguishable from the rest of the ring, exactly like the reference build.
*/
public class GateStructureScanner {
private static final BlockFace[] NEIGHBORS = {
BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
};
/** Result of a scan: either a successful structure, or a reason a player-facing message can be built from. */
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 chevronCount;
private final int maxFrameBlocks;
private final int maxIrisBlocks;
private final int minFrameBlocks;
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;
}
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;
}
/** Convenience wrapper for callers that only care about success/failure, not why. */
public GateStructure scan(Block seed) {
return scanWithDiagnostics(seed).structure;
}
/**
* Scans outward from the given seed block (the block the sign is attached to) and reports
* exactly why it failed if it did, instead of just returning null.
*/
public ScanResult scanWithDiagnostics(Block seed) {
Block frameSeed = seed;
if (!isFrameMaterial(frameSeed.getType())) {
for (BlockFace face : NEIGHBORS) {
Block b = frameSeed.getRelative(face);
if (isFrameMaterial(b.getType())) {
frameSeed = b;
break;
}
}
}
if (!isFrameMaterial(frameSeed.getType())) {
return ScanResult.fail("The sign isn't touching a block listed in gate.frame-materials. "
+ "Check the sign is mounted directly on the frame, and that every block type in your ring "
+ "is listed in config.yml.");
}
Set<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>();
queue.add(frameSeed);
frameKeys.add(key(frameSeed));
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 "
+ "(e.g. planks used elsewhere in your build touching the ring). 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). The ring may be too small or made of a material "
+ "that isn't in gate.frame-materials.");
}
// Find an interior seed: a non-frame block adjacent to a frame block, that is not
// solid (roughly the middle of the ring). We try several candidates and flood-fill
// each; the first one that stays enclosed within maxIrisBlocks wins.
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));
}
}
}
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 + "). Check for a 1-block hole anywhere in the ring "
+ "(including the floor/ceiling of the arch).");
}
/** Picks N frame blocks spaced evenly across the flood-fill order as chevron slots, remembering their resting look. */
private List<GateStructure.ChevronSlot> pickChevrons(List<Block> frameBlocks) {
int count = Math.max(0, Math.min(chevronCount, frameBlocks.size()));
List<GateStructure.ChevronSlot> slots = new ArrayList<>(count);
if (count == 0) return slots;
double step = frameBlocks.size() / (double) count;
Set<Integer> chosen = new HashSet<>();
for (int i = 0; i < count; i++) {
int idx = (int) Math.round(i * step) % frameBlocks.size();
while (chosen.contains(idx)) idx = (idx + 1) % frameBlocks.size();
chosen.add(idx);
Block block = frameBlocks.get(idx);
slots.add(new GateStructure.ChevronSlot(block, block.getType()));
}
return slots;
}
/** Flood-fills non-frame blocks starting at seed; fails (returns null) if it escapes the frame boundary. */
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 acting as a boundary, not part of the interior
queue.add(next);
}
}
return interior;
}
private long key(Block b) {
return (((long) b.getX() & 0x3FFFFFF) << 38) | (((long) (b.getY() + 512) & 0xFFF) << 26) | ((long) b.getZ() & 0x3FFFFFF);
}
}
@@ -0,0 +1,64 @@
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;
}
}
@@ -0,0 +1,112 @@
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);
}
}
}
+23 -11
View File
@@ -26,26 +26,38 @@ cross-server:
enabled: false enabled: false
gate: gate:
# Any of these materials count as the decorative frame ring. Mix and match to match # The exact shape of a gate, read top row to bottom row. This is matched precisely
# your build (the default matches an obsidian/gold-block/birch-planks arch). # 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).
frame-materials: frame-materials:
- OBSIDIAN - OBSIDIAN
- GOLD_BLOCK - GOLD_BLOCK
- GILDED_BLACKSTONE - GILDED_BLACKSTONE
- BIRCH_PLANKS - BIRCH_PLANKS
- OAK_PLANKS - OAK_PLANKS
# Chevrons are NOT a separate material you have to place - the plugin automatically
# picks this many frame blocks, evenly spaced around the ring, and swaps them to
# chevron-lit-material while dialing/open, then restores whatever they looked like
# at rest when the gate closes. So a chevron can rest as plain obsidian and only
# stand out once it lights up.
chevron-lit-material: GILDED_BLACKSTONE chevron-lit-material: GILDED_BLACKSTONE
chevron-count: 6
# Material poured into the interior (the "event horizon") while the gate is open. # Material poured into the interior (the "event horizon") while the gate is open.
iris-material: WATER 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 # 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 # 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. # DHD console away from the gate, instead of being mounted directly on the frame.