diff --git a/build.gradle.kts b/build.gradle.kts index 59ec765f..64fb2c31 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -76,7 +76,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.12.2") - testRuntimeOnly(libs.paper.api) + testImplementation(libs.paper.api) } fun Project.versionDetails(): VersionDetails { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java b/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java index 072cad36..1286a6e8 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java @@ -28,7 +28,7 @@ public class BuildTeamToolsCommand implements CommandExecutor, TabCompleter { public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label, String @NotNull [] args) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS); return true; } @@ -40,7 +40,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N switch (args[0].toLowerCase()) { case "communicators" -> { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_COMMUNICATORS)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_COMMUNICATORS); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_COMMUNICATORS); return true; } @@ -70,7 +70,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N private static void debugCommand(@NonNull CommandSender sender, String @NonNull [] args) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_DEBUG)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_DEBUG); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_DEBUG); return; } @@ -88,7 +88,7 @@ private static void debugCommand(@NonNull CommandSender sender, String @NonNull private static void cacheCommand(@NonNull CommandSender sender, String @NonNull [] args) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_CACHE)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CACHE); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CACHE); return; } @@ -110,7 +110,7 @@ private static void cacheCommand(@NonNull CommandSender sender, String @NonNull private static void reloadCommand(@NonNull CommandSender sender) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_RELOAD)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_RELOAD); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_RELOAD); return; } @@ -127,7 +127,7 @@ private static void reloadCommand(@NonNull CommandSender sender) { private void updateCommand(@NotNull CommandSender sender) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_UPDATE)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_UPDATE); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_UPDATE); return; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java b/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java index 828f255c..503e9656 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java @@ -5,7 +5,6 @@ import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.ModuleComponent; import net.buildtheearth.buildteamtools.modules.network.model.Permissions; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.utils.io.ConfigPaths; import org.bukkit.Bukkit; import org.bukkit.Sound; @@ -84,7 +83,7 @@ public void update(CommandSender sender, boolean checkMsg) { public void checkForUpdates(@NonNull CommandSender sender) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_CHECK_FOR_UPDATES)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CHECK_FOR_UPDATES); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CHECK_FOR_UPDATES); return; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java index 83809e8c..9d8f0622 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java @@ -6,9 +6,14 @@ import com.sk89q.worldedit.regions.CuboidRegion; import com.sk89q.worldedit.regions.Polygonal2DRegion; import com.sk89q.worldedit.regions.Region; +import lombok.Getter; +import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.generation.RailScripts; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorType; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import org.bukkit.Sound; import org.bukkit.entity.Player; @@ -20,8 +25,12 @@ public class Rail extends GeneratorComponent { private final Set preparingPlayers = ConcurrentHashMap.newKeySet(); + @Getter + private final RailTypeManager railTypeManager; + public Rail() { super(GeneratorType.RAIL); + railTypeManager = new RailTypeManager(BuildTeamTools.getInstance().getDataFolder()); } @Override @@ -38,7 +47,7 @@ public boolean checkForPlayer(Player player) { "Rail Generator supports cuboid, polygonal and convex WorldEdit selections." ))); player.closeInventory(); - player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0F, 1.0F); + playSound(player, Sound.ENTITY_ITEM_BREAK); return false; } @@ -50,6 +59,9 @@ private boolean isSupportedRailSelection(Region region) { @Override public void generate(Player player) { + if (!Permissions.checkPermission(player, Permissions.RAIL_GENERATOR_USE)) + return; + if (GeneratorModule.getInstance().isGenerating(player) || !preparingPlayers.add(player.getUniqueId())) { sendAlreadyGeneratingMessage(player); return; @@ -67,6 +79,10 @@ private void sendAlreadyGeneratingMessage(Player player) { player.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( "Rail Generator is already running. Please wait until the current generation is finished." ))); - player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0F, 1.0F); + playSound(player, Sound.ENTITY_ITEM_BREAK); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java deleted file mode 100644 index b2e130e8..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java +++ /dev/null @@ -1,268 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.alpsbte.alpslib.utils.GeneratorUtils; -import com.cryptomorin.xseries.XMaterial; -import com.sk89q.worldedit.util.Direction; -import com.sk89q.worldedit.world.block.BlockState; -import com.sk89q.worldedit.world.block.BlockTypes; -import org.bukkit.util.Vector; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -final class RailBlockBuilder { - - private static final Direction DEFAULT_FACING = Direction.EAST; - private static final XMaterial[] CENTER_MATERIALS = new XMaterial[]{ - XMaterial.DEAD_FIRE_CORAL_BLOCK, - XMaterial.STONE, - XMaterial.COBBLESTONE - }; - - private final List controlPoints; - private final RailTerrainResolver terrainResolver; - private final RailType railType; - private final RailPreparationProgress preparationProgress; - private final int railLaneCount; - private final int railLaneSpacing; - private final long terrainAdjustedPercentage; - private final long buildFinishedPercentage; - - RailBlockBuilder( - List controlPoints, - RailTerrainResolver terrainResolver, - RailType railType, - RailPreparationProgress preparationProgress, - int railLaneCount, - int railLaneSpacing, - long terrainAdjustedPercentage, - long buildFinishedPercentage - ) { - this.controlPoints = controlPoints; - this.terrainResolver = terrainResolver; - this.railType = railType; - this.preparationProgress = preparationProgress; - this.railLaneCount = railLaneCount; - this.railLaneSpacing = railLaneSpacing; - this.terrainAdjustedPercentage = terrainAdjustedPercentage; - this.buildFinishedPercentage = buildFinishedPercentage; - } - - Map build(List path) { - Map railBlocks = new LinkedHashMap<>(); - List> railCenterPaths = new RailLanePathBuilder(controlPoints, terrainResolver, railLaneCount, railLaneSpacing) - .createRailCenterPaths(path); - Set centerPositions = getCenterPositions(railCenterPaths); - Map sideBlocks = new LinkedHashMap<>(); - int totalPathPoints = getTotalPathPointCount(railCenterPaths); - int processedPathPoints = 0; - - for (List railCenterPath : railCenterPaths) { - for (int index = 0; index < railCenterPath.size(); index++) { - Vector center = railCenterPath.get(index); - - for (RailSidePlacement sidePlacement : getSidePlacements(railCenterPath, index)) - addSideBlock(sideBlocks, center, sidePlacement, centerPositions); - - processedPathPoints++; - preparationProgress.update(preparationProgress.scale(processedPathPoints, totalPathPoints, terrainAdjustedPercentage, 86L)); - } - } - - int processedSideBlocks = 0; - - for (RailSideBlock sideBlock : sideBlocks.values()) { - railBlocks.put(sideBlock.key(), createAnvilBlockState(resolveSideBlockFacing(sideBlock, sideBlocks))); - processedSideBlocks++; - preparationProgress.update(preparationProgress.scale(processedSideBlocks, sideBlocks.size(), 86L, 89L)); - } - - int processedCenterPoints = 0; - - for (List railCenterPath : railCenterPaths) { - for (Vector center : railCenterPath) { - railBlocks.put(PositionKey.from(center), createCenterBlockState(center)); - processedCenterPoints++; - preparationProgress.update(preparationProgress.scale(processedCenterPoints, totalPathPoints, 89L, buildFinishedPercentage)); - } - } - - return railBlocks; - } - - private int getTotalPathPointCount(List> railCenterPaths) { - int totalPathPoints = 0; - - for (List railCenterPath : railCenterPaths) - totalPathPoints += railCenterPath.size(); - - return Math.max(1, totalPathPoints); - } - - private List getSidePlacements(List path, int index) { - List placements = new ArrayList<>(); - Vector center = path.get(index); - RailStep previousStep = index > 0 ? getStep(path.get(index - 1), center) : null; - RailStep nextStep = index < path.size() - 1 ? getStep(center, path.get(index + 1)) : null; - - addSidePlacements(placements, getRailStep(path, index, new RailStep(1, 0))); - - if (previousStep != null) - addSidePlacements(placements, previousStep); - - if (nextStep != null) - addSidePlacements(placements, nextStep); - - return placements; - } - - private void addSidePlacements(List placements, RailStep step) { - if (step.dx() != 0 && step.dz() != 0) { - addSidePlacement(placements, new RailStep(step.dx(), 0), GeneratorUtils.getFacing(0, step.dz(), DEFAULT_FACING)); - addSidePlacement(placements, new RailStep(0, step.dz()), GeneratorUtils.getFacing(step.dx(), 0, DEFAULT_FACING)); - return; - } - - if (step.dx() != 0) { - Direction facing = GeneratorUtils.getFacing(step.dx(), 0, DEFAULT_FACING); - addSidePlacement(placements, new RailStep(0, 1), facing); - addSidePlacement(placements, new RailStep(0, -1), facing); - return; - } - - Direction facing = GeneratorUtils.getFacing(0, step.dz(), DEFAULT_FACING); - addSidePlacement(placements, new RailStep(1, 0), facing); - addSidePlacement(placements, new RailStep(-1, 0), facing); - } - - private void addSidePlacement(List placements, RailStep offset, Direction facing) { - for (RailSidePlacement placement : placements) { - if (placement.offset().equals(offset)) return; - } - - placements.add(new RailSidePlacement(offset, facing)); - } - - private void addSideBlock( - Map sideBlocks, - Vector center, - RailSidePlacement sidePlacement, - Set centerPositions - ) { - RailStep sideOffset = sidePlacement.offset(); - - if (sideOffset.dx() == 0 && sideOffset.dz() == 0) - return; - - int x = center.getBlockX() + sideOffset.dx(); - int z = center.getBlockZ() + sideOffset.dz(); - int y = terrainResolver.getNearestRailSurfaceY(x, z, center.getBlockY()); - - PositionKey key = PositionKey.of(x, y, z); - - if (centerPositions.contains(key)) - return; - - sideBlocks - .computeIfAbsent(key, ignored -> new RailSideBlock(key, DEFAULT_FACING)) - .addFacing(sidePlacement.facing()); - } - - private Direction resolveSideBlockFacing(RailSideBlock sideBlock, Map sideBlocks) { - PositionKey key = sideBlock.key(); - boolean east = sideBlocks.containsKey(PositionKey.of(key.x() + 1, key.y(), key.z())); - boolean west = sideBlocks.containsKey(PositionKey.of(key.x() - 1, key.y(), key.z())); - boolean south = sideBlocks.containsKey(PositionKey.of(key.x(), key.y(), key.z() + 1)); - boolean north = sideBlocks.containsKey(PositionKey.of(key.x(), key.y(), key.z() - 1)); - int xConnections = (east ? 1 : 0) + (west ? 1 : 0); - int zConnections = (south ? 1 : 0) + (north ? 1 : 0); - Direction preferredFacing = sideBlock.getPreferredFacing(); - - if (xConnections > zConnections) - return resolveAxisFacing(preferredFacing, Direction.EAST, Direction.WEST, east, west); - - if (zConnections > xConnections) - return resolveAxisFacing(preferredFacing, Direction.SOUTH, Direction.NORTH, south, north); - - return preferredFacing; - } - - private Direction resolveAxisFacing( - Direction preferredFacing, - Direction positiveFacing, - Direction negativeFacing, - boolean hasPositiveNeighbor, - boolean hasNegativeNeighbor - ) { - if (preferredFacing == positiveFacing && hasPositiveNeighbor || preferredFacing == negativeFacing && hasNegativeNeighbor) - return preferredFacing; - - if (hasPositiveNeighbor && !hasNegativeNeighbor) - return positiveFacing; - - if (hasNegativeNeighbor && !hasPositiveNeighbor) - return negativeFacing; - - return preferredFacing == negativeFacing ? negativeFacing : positiveFacing; - } - - private Set getCenterPositions(List> railCenterPaths) { - Set centerPositions = new HashSet<>(); - - for (List railCenterPath : railCenterPaths) - for (Vector center : railCenterPath) - centerPositions.add(PositionKey.from(center)); - - return centerPositions; - } - - private RailStep getRailStep(List path, int index, RailStep fallbackStep) { - RailStep previousStep = index > 0 ? getStep(path.get(index - 1), path.get(index)) : null; - RailStep nextStep = index < path.size() - 1 ? getStep(path.get(index), path.get(index + 1)) : null; - - if (previousStep != null && nextStep != null) { - int dx = Integer.compare(previousStep.dx() + nextStep.dx(), 0); - int dz = Integer.compare(previousStep.dz() + nextStep.dz(), 0); - - if (dx != 0 || dz != 0) return new RailStep(dx, dz); - } - - if (nextStep != null) return nextStep; - - if (previousStep != null) return previousStep; - - return fallbackStep; - } - - private RailStep getStep(Vector from, Vector to) { - int dx = Integer.compare(to.getBlockX() - from.getBlockX(), 0); - int dz = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); - - if (dx == 0 && dz == 0) return null; - - return new RailStep(dx, dz); - } - - private BlockState createCenterBlockState(Vector position) { - return switch (railType) { - case STANDARD -> createStandardCenterBlockState(position); - }; - } - - private BlockState createStandardCenterBlockState(Vector position) { - int index = Math.floorMod( - position.getBlockX() * 31 + position.getBlockY() * 23 + position.getBlockZ() * 17, - CENTER_MATERIALS.length - ); - - return GeneratorUtils.getBlockState(CENTER_MATERIALS[index]); - } - - private BlockState createAnvilBlockState(Direction direction) { - return GeneratorUtils.getBlockStateWithFacing(BlockTypes.ANVIL, direction); - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java index a60a1ccf..e3e0d1ad 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java @@ -7,7 +7,9 @@ public enum RailFlag implements Flag { - RAIL_TYPE("t", FlagType.RAIL_TYPE); + RAIL_TYPE("t", FlagType.RAIL_TYPE), + TRACK_COUNT("c", FlagType.INTEGER), + TRACK_SPACING("s", FlagType.INTEGER); @Getter private final String flag; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java deleted file mode 100644 index 62f2ae9d..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java +++ /dev/null @@ -1,211 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.alpsbte.alpslib.utils.GeneratorUtils; -import org.bukkit.util.Vector; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -final class RailLanePathBuilder { - - private final List controlPoints; - private final RailTerrainResolver terrainResolver; - private final int railLaneCount; - private final int railLaneSpacing; - - RailLanePathBuilder(List controlPoints, RailTerrainResolver terrainResolver, int railLaneCount, int railLaneSpacing) { - this.controlPoints = controlPoints; - this.terrainResolver = terrainResolver; - this.railLaneCount = railLaneCount; - this.railLaneSpacing = railLaneSpacing; - } - - List> createRailCenterPaths(List path) { - if (railLaneCount <= 1) - return List.of(path); - - List> railCenterPaths = new ArrayList<>(); - int sideLaneCount = (railLaneCount - 1) / 2; - - for (int laneIndex = sideLaneCount; laneIndex >= 1; laneIndex--) { - List leftLane = createShiftedRailLane(laneIndex * railLaneSpacing, 1); - - if (leftLane.size() >= 2) - railCenterPaths.add(leftLane); - } - - railCenterPaths.add(path); - - for (int laneIndex = 1; laneIndex <= sideLaneCount; laneIndex++) { - List rightLane = createShiftedRailLane(laneIndex * railLaneSpacing, -1); - - if (rightLane.size() >= 2) - railCenterPaths.add(rightLane); - } - - return railCenterPaths; - } - - private List createShiftedRailLane(int distance, int sideSign) { - List> shiftedLines = GeneratorUtils.shiftPointsAll(controlPoints, distance); - List candidatePoints = flattenShiftedLines(shiftedLines); - - if (candidatePoints.isEmpty()) - return Collections.emptyList(); - - List shiftedControlPoints = createShiftedControlPoints(candidatePoints, distance, sideSign); - - if (shiftedControlPoints.size() < 2) - return Collections.emptyList(); - - List shiftedPath = createCenterPath(shiftedControlPoints); - - if (shiftedPath.size() < 2) - return Collections.emptyList(); - - terrainResolver.adjustPathToTerrain(shiftedPath); - return shiftedPath; - } - - private List flattenShiftedLines(List> shiftedLines) { - if (shiftedLines == null || shiftedLines.isEmpty()) - return Collections.emptyList(); - - List candidatePoints = new ArrayList<>(); - - for (List shiftedLine : shiftedLines) { - if (shiftedLine == null || shiftedLine.isEmpty()) - continue; - - for (Vector point : shiftedLine) { - if (point != null) - candidatePoints.add(point); - } - } - - return candidatePoints; - } - - private List createShiftedControlPoints(List candidatePoints, int distance, int sideSign) { - List shiftedControlPoints = new ArrayList<>(); - - for (int index = 0; index < controlPoints.size(); index++) { - Vector basePoint = controlPoints.get(index); - Vector direction = getControlPointDirection(index); - - if (direction.lengthSquared() == 0) - continue; - - direction.normalize(); - - Vector normal = new Vector(-direction.getZ(), 0, direction.getX()); - - if (sideSign < 0) - normal.multiply(-1); - - Vector shiftedPoint = getBestShiftedPoint(basePoint, normal, candidatePoints, distance); - addIfDifferentFromPrevious(shiftedControlPoints, shiftedPoint); - } - - return shiftedControlPoints; - } - - private Vector getControlPointDirection(int index) { - if (controlPoints.size() < 2) - return new Vector(0, 0, 0); - - if (index == 0) - return getHorizontalDirection(controlPoints.get(0), controlPoints.get(1)); - - if (index == controlPoints.size() - 1) - return getHorizontalDirection(controlPoints.get(index - 1), controlPoints.get(index)); - - Vector previousDirection = getHorizontalDirection(controlPoints.get(index - 1), controlPoints.get(index)); - Vector nextDirection = getHorizontalDirection(controlPoints.get(index), controlPoints.get(index + 1)); - Vector combinedDirection = previousDirection.add(nextDirection); - - if (combinedDirection.lengthSquared() != 0) - return combinedDirection; - - if (nextDirection.lengthSquared() != 0) - return nextDirection; - - return previousDirection; - } - - private Vector getHorizontalDirection(Vector from, Vector to) { - return new Vector( - to.getBlockX() - from.getBlockX(), - 0, - to.getBlockZ() - from.getBlockZ() - ); - } - - private Vector getBestShiftedPoint(Vector basePoint, Vector normal, List candidatePoints, int distance) { - Vector idealPoint = getIdealShiftedPoint(basePoint, normal, distance); - Vector bestPoint = null; - double bestDistanceSquared = Double.MAX_VALUE; - - for (Vector candidatePoint : candidatePoints) { - double signedOffset = getSignedOffset(basePoint, candidatePoint, normal); - - if (signedOffset < 0.5D) - continue; - - double distanceSquared = getHorizontalDistanceSquared(candidatePoint, idealPoint); - - if (distanceSquared < bestDistanceSquared) { - bestDistanceSquared = distanceSquared; - bestPoint = candidatePoint; - } - } - - double maxCandidateDistanceSquared = Math.max(16D, distance * distance * 2.25D); - - if (bestPoint == null || bestDistanceSquared > maxCandidateDistanceSquared) - return idealPoint; - - return new Vector(bestPoint.getBlockX(), basePoint.getBlockY(), bestPoint.getBlockZ()); - } - - private Vector getIdealShiftedPoint(Vector basePoint, Vector normal, int distance) { - return new Vector( - basePoint.getBlockX() + (int) Math.round(normal.getX() * distance), - basePoint.getBlockY(), - basePoint.getBlockZ() + (int) Math.round(normal.getZ() * distance) - ); - } - - private double getSignedOffset(Vector basePoint, Vector candidatePoint, Vector normal) { - double offsetX = candidatePoint.getX() - basePoint.getX(); - double offsetZ = candidatePoint.getZ() - basePoint.getZ(); - - return offsetX * normal.getX() + offsetZ * normal.getZ(); - } - - private void addIfDifferentFromPrevious(List points, Vector point) { - if (points.isEmpty()) { - points.add(point); - return; - } - - Vector previousPoint = points.get(points.size() - 1); - - if (previousPoint.getBlockX() == point.getBlockX() && previousPoint.getBlockZ() == point.getBlockZ()) - return; - - points.add(point); - } - - private double getHorizontalDistanceSquared(Vector first, Vector second) { - double dx = first.getX() - second.getX(); - double dz = first.getZ() - second.getZ(); - - return dx * dx + dz * dz; - } - - private List createCenterPath(List points) { - return GeneratorUtils.removeOrthogonalCorners(GeneratorUtils.createShortestBlockPath(points)); - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java index ae244c3d..665681e6 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java @@ -1,5 +1,6 @@ package net.buildtheearth.buildteamtools.modules.generator.components.rail; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; import org.bukkit.entity.Player; @@ -11,6 +12,6 @@ public RailSettings(Player player) { @Override public void setDefaultValues() { - setValue(RailFlag.RAIL_TYPE, RailType.STANDARD); + setValue(RailFlag.RAIL_TYPE, RailType.getDefault()); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java deleted file mode 100644 index 6c641532..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.cryptomorin.xseries.XMaterial; -import lombok.Getter; -import org.jspecify.annotations.Nullable; - -public enum RailType { - - STANDARD("standard", "Standard", XMaterial.RAIL); - - @Getter - private final String identifier; - @Getter - private final String displayName; - @Getter - private final XMaterial icon; - - RailType(String identifier, String displayName, XMaterial icon) { - this.identifier = identifier; - this.displayName = displayName; - this.icon = icon; - } - - public static @Nullable RailType byString(String value) { - for (RailType railType : RailType.values()) { - if (railType.getIdentifier().equalsIgnoreCase(value) || railType.name().equalsIgnoreCase(value)) - return railType; - } - - return null; - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java new file mode 100644 index 00000000..ed03ad0d --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java @@ -0,0 +1,583 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import com.cryptomorin.xseries.XMaterial; +import lombok.Getter; +import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; +import org.bukkit.Material; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +@Getter +public final class RailType { + + public static final String STANDARD_IDENTIFIER = "standard"; + + public static final int MIN_TRACK_COUNT = 1; + public static final int MAX_TRACK_COUNT = 8; + public static final int MIN_TRACK_SPACING = 3; + public static final int MAX_TRACK_SPACING = 32; + // A sleeper spacing of 0 disables sleepers entirely. + public static final int MIN_SLEEPER_SPACING = 0; + public static final int MAX_SLEEPER_SPACING = 16; + public static final int MIN_OVERHEAD_POLE_SPACING = 1; + public static final int MAX_OVERHEAD_POLE_SPACING = 512; + public static final int MIN_OVERHEAD_POLE_OFFSET = 1; + public static final int MAX_OVERHEAD_POLE_OFFSET = 16; + public static final int MIN_OVERHEAD_POLE_HEIGHT = 3; + public static final int MAX_OVERHEAD_POLE_HEIGHT = 16; + public static final int MAX_IDENTIFIER_LENGTH = 32; + public static final int MAX_DISPLAY_NAME_LENGTH = 48; + + public static final int DEFAULT_TRACK_COUNT = 2; + public static final int DEFAULT_TRACK_SPACING = 5; + public static final int DEFAULT_OVERHEAD_POLE_SPACING = 16; + public static final int DEFAULT_OVERHEAD_POLE_OFFSET = 3; + public static final int DEFAULT_OVERHEAD_POLE_HEIGHT = 6; + + private static final String IDENTIFIER_PATTERN = "[a-z0-9_-]+"; + + private final String identifier; + private final String displayName; + private final XMaterial icon; + private final List railBlocks; + private final List blocksBelow; + private final List sleeperBlocks; + private final int sleeperSpacing; + private final int trackCount; + private final int trackSpacing; + private final List trackSpacings; + private final boolean overheadPolesEnabled; + private final List overheadPoleBlocks; + private final List overheadSupportBlocks; + private final int overheadPoleSpacing; + private final int overheadPoleOffset; + private final int overheadPoleHeight; + private final boolean overheadWiresEnabled; + private final List overheadWireBlocks; + private final boolean trackSwitchesEnabled; + private final boolean builtIn; + + RailType(Configuration configuration, boolean builtIn) { + List configuredBlocksBelow = configuration.blocksBelow(); + + this.identifier = configuration.identifier(); + this.displayName = configuration.displayName(); + this.icon = configuration.icon(); + this.railBlocks = List.copyOf(configuration.railBlocks()); + this.blocksBelow = configuredBlocksBelow == null ? List.of() : List.copyOf(configuredBlocksBelow); + this.sleeperBlocks = List.copyOf(configuration.sleeperBlocks()); + this.sleeperSpacing = configuration.sleeperSpacing(); + this.trackCount = configuration.trackCount(); + this.trackSpacing = configuration.trackSpacing(); + this.trackSpacings = normalizeTrackSpacings( + configuration.trackSpacings(), + configuration.trackCount(), + configuration.trackSpacing() + ); + this.overheadPolesEnabled = configuration.overheadPolesEnabled(); + this.overheadPoleBlocks = List.copyOf(configuration.overheadPoleBlocks()); + this.overheadSupportBlocks = List.copyOf(configuration.overheadSupportBlocks()); + this.overheadPoleSpacing = configuration.overheadPoleSpacing(); + this.overheadPoleOffset = configuration.overheadPoleOffset(); + this.overheadPoleHeight = configuration.overheadPoleHeight(); + this.overheadWiresEnabled = configuration.overheadWiresEnabled(); + this.overheadWireBlocks = List.copyOf(configuration.overheadWireBlocks()); + this.trackSwitchesEnabled = configuration.trackSwitchesEnabled(); + this.builtIn = builtIn; + } + + public @Nullable XMaterial getRailBlock() { + return railBlocks.isEmpty() ? null : railBlocks.getFirst(); + } + + public @Nullable XMaterial getSleeperBlock() { + return sleeperBlocks.isEmpty() ? null : sleeperBlocks.getFirst(); + } + + public @Nullable XMaterial getOverheadPoleBlock() { + return overheadPoleBlocks.isEmpty() ? null : overheadPoleBlocks.getFirst(); + } + + public @Nullable XMaterial getOverheadSupportBlock() { + return overheadSupportBlocks.isEmpty() ? null : overheadSupportBlocks.getFirst(); + } + + public @Nullable XMaterial getOverheadWireBlock() { + return overheadWireBlocks.isEmpty() ? null : overheadWireBlocks.getFirst(); + } + + public boolean hasSleepers() { + return sleeperSpacing > 0 && !sleeperBlocks.isEmpty(); + } + + public boolean hasOverheadPoles() { + return overheadPolesEnabled && !overheadPoleBlocks.isEmpty(); + } + + public boolean hasOverheadWires() { + return overheadWiresEnabled && !overheadWireBlocks.isEmpty(); + } + + /** + * Creates a custom rail type with the rail block as menu icon. + * Call {@link RailTypeManager#saveRailType(RailType)} to validate and persist it. + */ + public static RailType createCustom(Configuration configuration) { + return new RailType(configuration, false); + } + + static RailType createStandard() { + return new RailType(new Configuration() + .identifier(STANDARD_IDENTIFIER) + .displayName("Standard") + .icon(XMaterial.RAIL) + .railBlock(XMaterial.ANVIL) + .blocksBelow(List.of(XMaterial.DEAD_FIRE_CORAL_BLOCK, XMaterial.STONE, XMaterial.COBBLESTONE)) + .sleeperBlock(XMaterial.SPRUCE_PLANKS) + .sleeperSpacing(0) + .trackCount(DEFAULT_TRACK_COUNT) + .trackSpacing(DEFAULT_TRACK_SPACING) + .trackSpacings(List.of(DEFAULT_TRACK_SPACING)) + .overheadPolesEnabled(true) + .overheadPoleBlock(XMaterial.LIGHT_GRAY_CONCRETE) + .overheadSupportBlock(XMaterial.LIGHT_GRAY_CONCRETE) + .overheadPoleSpacing(DEFAULT_OVERHEAD_POLE_SPACING) + .overheadPoleOffset(DEFAULT_OVERHEAD_POLE_OFFSET) + .overheadPoleHeight(DEFAULT_OVERHEAD_POLE_HEIGHT) + .overheadWiresEnabled(true) + .overheadWireBlock(XMaterial.IRON_BARS) + .trackSwitchesEnabled(false), true); + } + + /** + * Validates all configurable rail type values. + * + * @return A human-readable error message, or null if all values are valid. + */ + public static @Nullable String validate(Configuration configuration) { + String error = validateIdentifier(configuration.identifier()); + + if (error != null) + return error; + + error = validateDisplayName(configuration.displayName()); + + if (error != null) + return error; + + error = validateBlocks(configuration); + + if (error != null) + return error; + + error = validateSleeper(configuration); + + if (error != null) + return error; + + error = validateTracks(configuration); + + if (error != null) + return error; + + return validateOverheadWires(configuration); + } + + private static @Nullable String validateIdentifier(@Nullable String identifier) { + if (!hasInvalidIdentifier(identifier)) + return null; + + return "Rail type name must be %s characters or less and may only contain letters, numbers, '-' and '_'." + .formatted(MAX_IDENTIFIER_LENGTH); + } + + private static @Nullable String validateDisplayName(@Nullable String displayName) { + if (displayName != null && !displayName.isBlank() && displayName.length() <= MAX_DISPLAY_NAME_LENGTH) + return null; + + return "Rail type display name must not be empty and must be %s characters or less." + .formatted(MAX_DISPLAY_NAME_LENGTH); + } + + private static @Nullable String validateBlocks(Configuration configuration) { + if (!isPlaceablePalette(configuration.railBlocks())) + return "Rail block must be a valid placeable Minecraft block."; + + List blocksBelow = configuration.blocksBelow(); + + if (blocksBelow == null || blocksBelow.isEmpty()) + return "Rail type needs at least one block below the rails."; + + for (XMaterial blockBelow : blocksBelow) + if (!isPlaceableBlock(blockBelow)) + return "Blocks below the rails must be valid placeable Minecraft blocks."; + + return null; + } + + private static @Nullable String validateSleeper(Configuration configuration) { + int sleeperSpacing = configuration.sleeperSpacing(); + + if (sleeperSpacing < MIN_SLEEPER_SPACING || sleeperSpacing > MAX_SLEEPER_SPACING) + return "Sleeper spacing must be between %s and %s. Use 0 to disable sleepers." + .formatted(MIN_SLEEPER_SPACING, MAX_SLEEPER_SPACING); + + if ((sleeperSpacing > 0 || !configuration.sleeperBlocks().isEmpty()) + && !isPlaceablePalette(configuration.sleeperBlocks())) + return "Sleeper block must be a valid placeable Minecraft block."; + + return null; + } + + private static @Nullable String validateTracks(Configuration configuration) { + int trackCount = configuration.trackCount(); + + if (trackCount < MIN_TRACK_COUNT || trackCount > MAX_TRACK_COUNT) + return "Track count must be between %s and %s.".formatted(MIN_TRACK_COUNT, MAX_TRACK_COUNT); + + int trackSpacing = configuration.trackSpacing(); + + if (trackSpacing < MIN_TRACK_SPACING || trackSpacing > MAX_TRACK_SPACING) + return "Track spacing must be between %s and %s.".formatted(MIN_TRACK_SPACING, MAX_TRACK_SPACING); + + List trackSpacings = configuration.trackSpacings(); + + if (trackSpacings == null) + return null; + + if (trackSpacings.size() != Math.max(0, trackCount - 1)) + return "Rail type needs exactly one spacing value between every pair of tracks."; + + for (Integer spacing : trackSpacings) + if (spacing == null || spacing < MIN_TRACK_SPACING || spacing > MAX_TRACK_SPACING) + return "Every track spacing must be between %s and %s." + .formatted(MIN_TRACK_SPACING, MAX_TRACK_SPACING); + + return null; + } + + private static List normalizeTrackSpacings(@Nullable List configured, int trackCount, int fallback) { + int spacingCount = Math.max(0, trackCount - 1); + + if (configured != null && configured.size() == spacingCount) + return List.copyOf(configured); + + return java.util.Collections.nCopies(spacingCount, fallback); + } + + private static @Nullable String validateOverheadWires(Configuration configuration) { + String poleError = validateOverheadPoles(configuration); + + if (poleError != null) + return poleError; + + if ((configuration.overheadWiresEnabled() || !configuration.overheadWireBlocks().isEmpty()) + && !isPlaceablePalette(configuration.overheadWireBlocks())) + return "Overhead wire block must be a valid placeable Minecraft block."; + + return null; + } + + private static @Nullable String validateOverheadPoles(Configuration configuration) { + if ((configuration.overheadPolesEnabled() || !configuration.overheadPoleBlocks().isEmpty()) + && !isPlaceablePalette(configuration.overheadPoleBlocks())) + return "Overhead pole block must be a valid placeable Minecraft block."; + + if ((configuration.overheadPolesEnabled() || !configuration.overheadSupportBlocks().isEmpty()) + && !isPlaceablePalette(configuration.overheadSupportBlocks())) + return "Overhead support block must be a valid placeable Minecraft block."; + + return configuration.overheadPolesEnabled() ? validateOverheadPoleMeasurements(configuration) : null; + } + + private static @Nullable String validateOverheadPoleMeasurements(Configuration configuration) { + if (configuration.overheadPoleSpacing() < MIN_OVERHEAD_POLE_SPACING + || configuration.overheadPoleSpacing() > MAX_OVERHEAD_POLE_SPACING) + return "Overhead pole spacing must be between %s and %s." + .formatted(MIN_OVERHEAD_POLE_SPACING, MAX_OVERHEAD_POLE_SPACING); + + if (configuration.overheadPoleOffset() < MIN_OVERHEAD_POLE_OFFSET + || configuration.overheadPoleOffset() > MAX_OVERHEAD_POLE_OFFSET) + return "Overhead pole offset must be between %s and %s." + .formatted(MIN_OVERHEAD_POLE_OFFSET, MAX_OVERHEAD_POLE_OFFSET); + + if (configuration.overheadPoleHeight() < MIN_OVERHEAD_POLE_HEIGHT + || configuration.overheadPoleHeight() > MAX_OVERHEAD_POLE_HEIGHT) + return "Overhead pole height must be between %s and %s." + .formatted(MIN_OVERHEAD_POLE_HEIGHT, MAX_OVERHEAD_POLE_HEIGHT); + + return null; + } + + private static boolean hasInvalidIdentifier(@Nullable String identifier) { + return identifier == null + || identifier.isBlank() + || identifier.length() > MAX_IDENTIFIER_LENGTH + || !identifier.toLowerCase().matches(IDENTIFIER_PATTERN); + } + + private static boolean isPlaceablePalette(List materials) { + return !materials.isEmpty() && materials.stream().allMatch(RailType::isPlaceableBlock); + } + + private static boolean isPlaceableBlock(@Nullable XMaterial material) { + if (material == null) + return false; + + Material bukkitMaterial = material.get(); + return bukkitMaterial != null && bukkitMaterial.isBlock(); + } + + public static @Nullable RailType byString(String value) { + Rail rail = GeneratorModule.getInstance().getRail(); + + return rail == null ? null : rail.getRailTypeManager().byString(value); + } + + public static RailType getDefault() { + Rail rail = GeneratorModule.getInstance().getRail(); + + return rail == null ? createStandard() : rail.getRailTypeManager().getDefault(); + } + + public static final class Configuration { + + private @Nullable String identifier; + private @Nullable String displayName; + private @Nullable XMaterial icon; + private List railBlocks = List.of(); + private @Nullable List blocksBelow; + private List sleeperBlocks = List.of(); + private int sleeperSpacing; + private int trackCount; + private int trackSpacing; + private @Nullable List trackSpacings; + private boolean overheadPolesEnabled; + private List overheadPoleBlocks = List.of(); + private List overheadSupportBlocks = List.of(); + private int overheadPoleSpacing = DEFAULT_OVERHEAD_POLE_SPACING; + private int overheadPoleOffset = DEFAULT_OVERHEAD_POLE_OFFSET; + private int overheadPoleHeight = DEFAULT_OVERHEAD_POLE_HEIGHT; + private boolean overheadWiresEnabled; + private List overheadWireBlocks = List.of(); + private boolean trackSwitchesEnabled; + + public @Nullable String identifier() { + return identifier; + } + + public Configuration identifier(@Nullable String identifier) { + this.identifier = identifier; + return this; + } + + public @Nullable String displayName() { + return displayName; + } + + public Configuration displayName(@Nullable String displayName) { + this.displayName = displayName; + return this; + } + + public @Nullable XMaterial icon() { + return icon; + } + + public Configuration icon(@Nullable XMaterial icon) { + this.icon = icon; + return this; + } + + public List railBlocks() { + return railBlocks; + } + + public Configuration railBlocks(List materials) { + this.railBlocks = new java.util.ArrayList<>(materials); + return this; + } + + public @Nullable XMaterial railBlock() { + return railBlocks.isEmpty() ? null : railBlocks.getFirst(); + } + + public Configuration railBlock(@Nullable XMaterial railBlock) { + this.railBlocks = railBlock == null ? List.of() : List.of(railBlock); + return this; + } + + public @Nullable List blocksBelow() { + return blocksBelow; + } + + public Configuration blocksBelow(@Nullable List blocksBelow) { + this.blocksBelow = blocksBelow; + return this; + } + + public List sleeperBlocks() { + return sleeperBlocks; + } + + public Configuration sleeperBlocks(List materials) { + this.sleeperBlocks = new java.util.ArrayList<>(materials); + return this; + } + + public @Nullable XMaterial sleeperBlock() { + return sleeperBlocks.isEmpty() ? null : sleeperBlocks.getFirst(); + } + + public Configuration sleeperBlock(@Nullable XMaterial sleeperBlock) { + this.sleeperBlocks = sleeperBlock == null ? List.of() : List.of(sleeperBlock); + return this; + } + + public int sleeperSpacing() { + return sleeperSpacing; + } + + public Configuration sleeperSpacing(int sleeperSpacing) { + this.sleeperSpacing = sleeperSpacing; + return this; + } + + public int trackCount() { + return trackCount; + } + + public Configuration trackCount(int trackCount) { + this.trackCount = trackCount; + return this; + } + + public int trackSpacing() { + return trackSpacing; + } + + public Configuration trackSpacing(int trackSpacing) { + this.trackSpacing = trackSpacing; + return this; + } + + public @Nullable List trackSpacings() { + return trackSpacings; + } + + public Configuration trackSpacings(@Nullable List trackSpacings) { + this.trackSpacings = trackSpacings; + return this; + } + + public boolean overheadPolesEnabled() { + return overheadPolesEnabled; + } + + public Configuration overheadPolesEnabled(boolean overheadPolesEnabled) { + this.overheadPolesEnabled = overheadPolesEnabled; + return this; + } + + public List overheadPoleBlocks() { + return overheadPoleBlocks; + } + + public Configuration overheadPoleBlocks(List materials) { + this.overheadPoleBlocks = new java.util.ArrayList<>(materials); + return this; + } + + public @Nullable XMaterial overheadPoleBlock() { + return overheadPoleBlocks.isEmpty() ? null : overheadPoleBlocks.getFirst(); + } + + public Configuration overheadPoleBlock(@Nullable XMaterial overheadPoleBlock) { + this.overheadPoleBlocks = overheadPoleBlock == null ? List.of() : List.of(overheadPoleBlock); + return this; + } + + public List overheadSupportBlocks() { + return overheadSupportBlocks; + } + + public Configuration overheadSupportBlocks(List materials) { + this.overheadSupportBlocks = new java.util.ArrayList<>(materials); + return this; + } + + public @Nullable XMaterial overheadSupportBlock() { + return overheadSupportBlocks.isEmpty() ? null : overheadSupportBlocks.getFirst(); + } + + public Configuration overheadSupportBlock(@Nullable XMaterial overheadSupportBlock) { + this.overheadSupportBlocks = overheadSupportBlock == null ? List.of() : List.of(overheadSupportBlock); + return this; + } + + public int overheadPoleSpacing() { + return overheadPoleSpacing; + } + + public Configuration overheadPoleSpacing(int overheadPoleSpacing) { + this.overheadPoleSpacing = overheadPoleSpacing; + return this; + } + + public int overheadPoleOffset() { + return overheadPoleOffset; + } + + public Configuration overheadPoleOffset(int overheadPoleOffset) { + this.overheadPoleOffset = overheadPoleOffset; + return this; + } + + public int overheadPoleHeight() { + return overheadPoleHeight; + } + + public Configuration overheadPoleHeight(int overheadPoleHeight) { + this.overheadPoleHeight = overheadPoleHeight; + return this; + } + + public boolean overheadWiresEnabled() { + return overheadWiresEnabled; + } + + public Configuration overheadWiresEnabled(boolean overheadWiresEnabled) { + this.overheadWiresEnabled = overheadWiresEnabled; + return this; + } + + public List overheadWireBlocks() { + return overheadWireBlocks; + } + + public Configuration overheadWireBlocks(List materials) { + this.overheadWireBlocks = new java.util.ArrayList<>(materials); + return this; + } + + public @Nullable XMaterial overheadWireBlock() { + return overheadWireBlocks.isEmpty() ? null : overheadWireBlocks.getFirst(); + } + + public Configuration overheadWireBlock(@Nullable XMaterial overheadWireBlock) { + this.overheadWireBlocks = overheadWireBlock == null ? List.of() : List.of(overheadWireBlock); + return this; + } + + public boolean trackSwitchesEnabled() { + return trackSwitchesEnabled; + } + + public Configuration trackSwitchesEnabled(boolean trackSwitchesEnabled) { + this.trackSwitchesEnabled = trackSwitchesEnabled; + return this; + } + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java new file mode 100644 index 00000000..f92ee5c6 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java @@ -0,0 +1,515 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import com.alpsbte.alpslib.utils.ChatHelper; +import com.cryptomorin.xseries.XMaterial; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jspecify.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Holds all available rail types and persists player-created rail types to disk. + * The built-in standard rail type is always available and cannot be overwritten or deleted. + */ +public class RailTypeManager { + + private static final String FILE_PATH = "modules/generator/rail-types.yml"; + private static final String SCHEMA_VERSION_KEY = "schema-version"; + static final int CURRENT_SCHEMA_VERSION = 5; + private static final String TYPES_SECTION = "rail-types"; + + private static final String KEY_DISPLAY_NAME = "display-name"; + private static final String KEY_ICON = "icon"; + private static final String KEY_RAIL_BLOCK = "rail-block"; + private static final String KEY_BLOCKS_BELOW = "blocks-below"; + private static final String KEY_SLEEPER_BLOCK = "sleeper-block"; + private static final String KEY_SLEEPER_SPACING = "sleeper-spacing"; + private static final String KEY_TRACK_COUNT = "track-count"; + private static final String KEY_TRACK_SPACING = "track-spacing"; + private static final String KEY_TRACK_SPACINGS = "track-spacings"; + private static final String KEY_OVERHEAD_POLES_ENABLED = "overhead.poles.enabled"; + private static final String KEY_OVERHEAD_POLE_BLOCK = "overhead.poles.block"; + private static final String KEY_OVERHEAD_SUPPORT_BLOCK = "overhead.support.block"; + private static final String KEY_OVERHEAD_POLE_SPACING = "overhead.poles.spacing"; + private static final String KEY_OVERHEAD_POLE_OFFSET = "overhead.poles.offset"; + private static final String KEY_OVERHEAD_POLE_HEIGHT = "overhead.poles.height"; + private static final String KEY_OVERHEAD_WIRES_ENABLED = "overhead.wires.enabled"; + private static final String KEY_OVERHEAD_WIRE_BLOCK = "overhead.wires.block"; + private static final String KEY_TRACK_SWITCHES_ENABLED = "track-switches.enabled"; + + private final Map railTypes = new LinkedHashMap<>(); + private final File file; + + public RailTypeManager(File dataFolder) { + this.file = new File(dataFolder, FILE_PATH); + reload(); + } + + public void reload() { + railTypes.clear(); + + RailType standard = RailType.createStandard(); + railTypes.put(standard.getIdentifier(), standard); + + if (!file.exists()) + return; + + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + migrateStorage(config); + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) + loadRailType(typesSection.getConfigurationSection(identifier), identifier); + } + + public Collection getRailTypes() { + return Collections.unmodifiableCollection(railTypes.values()); + } + + public RailType getDefault() { + return railTypes.get(RailType.STANDARD_IDENTIFIER); + } + + /** + * @return The first free "custom-N" identifier for a new player-created rail type. + */ + public String getNextCustomIdentifier() { + int index = 1; + + while (railTypes.containsKey("custom-" + index)) + index++; + + return "custom-" + index; + } + + /** Names are allocated separately from stable identifiers, so renaming frees a display name. */ + public String getNextCustomDisplayName() { + return getNextCustomDisplayName(railTypes.values().stream().map(RailType::getDisplayName).toList()); + } + + static String getNextCustomDisplayName(Collection displayNames) { + Set names = new HashSet<>(); + for (String displayName : displayNames) + names.add(displayName.toLowerCase(Locale.ROOT)); + + int index = 1; + while (names.contains("custom rail " + index)) + index++; + return "Custom Rail " + index; + } + + public @Nullable RailType byString(@Nullable String value) { + if (value == null) + return null; + + RailType byIdentifier = railTypes.get(value.toLowerCase(Locale.ROOT)); + + if (byIdentifier != null) + return byIdentifier; + + for (RailType railType : railTypes.values()) + if (railType.getDisplayName().equalsIgnoreCase(value)) + return railType; + + return null; + } + + /** + * Validates and stores a custom rail type and saves it to disk. + * + * @return A human-readable error message, or null if the rail type was saved successfully. + */ + public @Nullable String saveRailType(RailType railType) { + String error = RailType.validate(toConfiguration(railType)); + + if (error != null) + return error; + + RailType existingRailType = railTypes.get(railType.getIdentifier()); + + if (existingRailType != null && existingRailType.isBuiltIn()) + return "The rail type '%s' is built-in and cannot be overwritten.".formatted(railType.getIdentifier()); + + railTypes.put(railType.getIdentifier(), railType); + String saveError = save(); + + if (saveError == null) + return null; + + if (existingRailType == null) + railTypes.remove(railType.getIdentifier()); + else + railTypes.put(existingRailType.getIdentifier(), existingRailType); + + return saveError; + } + + /** + * Deletes a custom rail type and saves the change to disk. + * + * @return A human-readable error message, or null if the rail type was deleted successfully. + */ + public @Nullable String deleteRailType(String identifier) { + RailType railType = railTypes.get(identifier.toLowerCase(Locale.ROOT)); + + if (railType == null) + return "The rail type '%s' does not exist.".formatted(identifier); + + if (railType.isBuiltIn()) + return "The rail type '%s' is built-in and cannot be deleted.".formatted(identifier); + + railTypes.remove(railType.getIdentifier()); + String saveError = save(); + + if (saveError == null) + return null; + + railTypes.put(railType.getIdentifier(), railType); + return saveError; + } + + /** + * Deletes multiple custom types atomically from the in-memory collection and + * writes the storage file once. Built-in or missing identifiers abort the operation. + */ + public @Nullable String deleteRailTypes(Collection identifiers) { + List typesToDelete = new ArrayList<>(); + + for (String identifier : identifiers) { + RailType railType = railTypes.get(identifier.toLowerCase(Locale.ROOT)); + + if (railType == null) + return "The rail type '%s' does not exist.".formatted(identifier); + + if (railType.isBuiltIn()) + return "The rail type '%s' is built-in and cannot be deleted.".formatted(identifier); + + typesToDelete.add(railType); + } + + for (RailType railType : typesToDelete) + railTypes.remove(railType.getIdentifier()); + + String error = save(); + + if (error == null) + return null; + + for (RailType railType : typesToDelete) + railTypes.put(railType.getIdentifier(), railType); + + return error; + } + + private void loadRailType(@Nullable ConfigurationSection section, String identifier) { + if (section == null) + return; + + String normalizedIdentifier = identifier.toLowerCase(Locale.ROOT); + String displayName = section.getString(KEY_DISPLAY_NAME, identifier); + List railBlocks = parsePalette(section.get(KEY_RAIL_BLOCK)); + List blocksBelow = parseMaterials(section.getStringList(KEY_BLOCKS_BELOW)); + List sleeperBlocks = parsePalette(section.get(KEY_SLEEPER_BLOCK)); + int sleeperSpacing = section.getInt(KEY_SLEEPER_SPACING, RailType.MIN_SLEEPER_SPACING); + int trackCount = section.getInt(KEY_TRACK_COUNT, RailType.DEFAULT_TRACK_COUNT); + int trackSpacing = section.getInt(KEY_TRACK_SPACING, RailType.DEFAULT_TRACK_SPACING); + List trackSpacings = section.contains(KEY_TRACK_SPACINGS) + ? section.getIntegerList(KEY_TRACK_SPACINGS) + : Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); + boolean overheadPolesEnabled = section.getBoolean(KEY_OVERHEAD_POLES_ENABLED, false); + List overheadPoleBlocks = parsePalette(section.get(KEY_OVERHEAD_POLE_BLOCK)); + List overheadSupportBlocks = parsePalette(section.get(KEY_OVERHEAD_SUPPORT_BLOCK)); + int overheadPoleSpacing = section.getInt( + KEY_OVERHEAD_POLE_SPACING, + RailType.DEFAULT_OVERHEAD_POLE_SPACING + ); + int overheadPoleOffset = section.getInt( + KEY_OVERHEAD_POLE_OFFSET, + RailType.DEFAULT_OVERHEAD_POLE_OFFSET + ); + int overheadPoleHeight = section.getInt( + KEY_OVERHEAD_POLE_HEIGHT, + RailType.DEFAULT_OVERHEAD_POLE_HEIGHT + ); + boolean overheadWiresEnabled = section.getBoolean(KEY_OVERHEAD_WIRES_ENABLED, false); + List overheadWireBlocks = parsePalette(section.get(KEY_OVERHEAD_WIRE_BLOCK)); + boolean trackSwitchesEnabled = section.getBoolean(KEY_TRACK_SWITCHES_ENABLED, false); + + String error = RailType.validate(new RailType.Configuration() + .identifier(normalizedIdentifier) + .displayName(displayName) + .icon(railBlocks.isEmpty() ? null : railBlocks.getFirst()) + .railBlocks(railBlocks) + .blocksBelow(blocksBelow) + .sleeperBlocks(sleeperBlocks) + .sleeperSpacing(sleeperSpacing) + .trackCount(trackCount) + .trackSpacing(trackSpacing) + .trackSpacings(trackSpacings) + .overheadPolesEnabled(overheadPolesEnabled) + .overheadPoleBlocks(overheadPoleBlocks) + .overheadSupportBlocks(overheadSupportBlocks) + .overheadPoleSpacing(overheadPoleSpacing) + .overheadPoleOffset(overheadPoleOffset) + .overheadPoleHeight(overheadPoleHeight) + .overheadWiresEnabled(overheadWiresEnabled) + .overheadWireBlocks(overheadWireBlocks) + .trackSwitchesEnabled(trackSwitchesEnabled)); + + if (error != null) { + ChatHelper.logError("Skipping invalid rail type '%s' in %s: %s", identifier, FILE_PATH, error); + return; + } + + if (railTypes.containsKey(normalizedIdentifier)) { + ChatHelper.logError("Skipping duplicate rail type '%s' in %s.", identifier, FILE_PATH); + return; + } + + XMaterial icon = parseMaterial(section.getString(KEY_ICON)); + + railTypes.put(normalizedIdentifier, new RailType(new RailType.Configuration() + .identifier(normalizedIdentifier) + .displayName(displayName) + .icon(icon == null ? railBlocks.getFirst() : icon) + .railBlocks(railBlocks) + .blocksBelow(blocksBelow) + .sleeperBlocks(sleeperBlocks) + .sleeperSpacing(sleeperSpacing) + .trackCount(trackCount) + .trackSpacing(trackSpacing) + .trackSpacings(trackSpacings) + .overheadPolesEnabled(overheadPolesEnabled) + .overheadPoleBlocks(overheadPoleBlocks) + .overheadSupportBlocks(overheadSupportBlocks) + .overheadPoleSpacing(overheadPoleSpacing) + .overheadPoleOffset(overheadPoleOffset) + .overheadPoleHeight(overheadPoleHeight) + .overheadWiresEnabled(overheadWiresEnabled) + .overheadWireBlocks(overheadWireBlocks) + .trackSwitchesEnabled(trackSwitchesEnabled), false)); + } + + private @Nullable String save() { + YamlConfiguration config = new YamlConfiguration(); + config.set(SCHEMA_VERSION_KEY, CURRENT_SCHEMA_VERSION); + + for (RailType railType : railTypes.values()) { + if (railType.isBuiltIn()) + continue; + + String path = TYPES_SECTION + "." + railType.getIdentifier(); + + config.set(path + "." + KEY_DISPLAY_NAME, railType.getDisplayName()); + config.set(path + "." + KEY_ICON, railType.getIcon().name()); + config.set(path + "." + KEY_RAIL_BLOCK, railType.getRailBlocks().stream().map(XMaterial::name).toList()); + config.set(path + "." + KEY_BLOCKS_BELOW, railType.getBlocksBelow().stream().map(XMaterial::name).toList()); + + config.set(path + "." + KEY_SLEEPER_BLOCK, railType.getSleeperBlocks().stream().map(XMaterial::name).toList()); + + config.set(path + "." + KEY_SLEEPER_SPACING, railType.getSleeperSpacing()); + config.set(path + "." + KEY_TRACK_COUNT, railType.getTrackCount()); + config.set(path + "." + KEY_TRACK_SPACING, railType.getTrackSpacing()); + config.set(path + "." + KEY_TRACK_SPACINGS, railType.getTrackSpacings()); + config.set(path + "." + KEY_OVERHEAD_POLES_ENABLED, railType.isOverheadPolesEnabled()); + config.set(path + "." + KEY_OVERHEAD_POLE_SPACING, railType.getOverheadPoleSpacing()); + config.set(path + "." + KEY_OVERHEAD_POLE_OFFSET, railType.getOverheadPoleOffset()); + config.set(path + "." + KEY_OVERHEAD_POLE_HEIGHT, railType.getOverheadPoleHeight()); + config.set(path + "." + KEY_OVERHEAD_WIRES_ENABLED, railType.isOverheadWiresEnabled()); + config.set(path + "." + KEY_TRACK_SWITCHES_ENABLED, railType.isTrackSwitchesEnabled()); + + config.set(path + "." + KEY_OVERHEAD_POLE_BLOCK, railType.getOverheadPoleBlocks().stream().map(XMaterial::name).toList()); + + config.set(path + "." + KEY_OVERHEAD_SUPPORT_BLOCK, railType.getOverheadSupportBlocks().stream().map(XMaterial::name).toList()); + + config.set(path + "." + KEY_OVERHEAD_WIRE_BLOCK, railType.getOverheadWireBlocks().stream().map(XMaterial::name).toList()); + } + + try { + if (file.getParentFile() != null && !file.getParentFile().exists() && !file.getParentFile().mkdirs()) + throw new IOException("Could not create directory " + file.getParentFile()); + + config.save(file); + return null; + } catch (IOException exception) { + ChatHelper.logError("Could not save rail types to %s.", exception, FILE_PATH); + return "Rail types could not be saved. Please check the server console."; + } + } + + private @Nullable XMaterial parseMaterial(@Nullable String value) { + if (value == null || value.isBlank()) + return null; + + return XMaterial.matchXMaterial(value).orElse(null); + } + + private List parseMaterials(List values) { + List materials = new ArrayList<>(); + + for (String value : values) { + XMaterial material = parseMaterial(value); + + // Keep invalid entries as null so validation reports them instead of silently dropping them. + materials.add(material); + } + + return materials; + } + + private void migrateStorage(YamlConfiguration config) { + int schemaVersion = config.getInt(SCHEMA_VERSION_KEY, 1); + + if (schemaVersion > CURRENT_SCHEMA_VERSION) { + ChatHelper.logError( + "Rail type storage %s uses unsupported schema version %s. Loading known fields without rewriting it.", + FILE_PATH, + schemaVersion + ); + return; + } + + if (schemaVersion == CURRENT_SCHEMA_VERSION) + return; + + migrateVersionOneToTwo(config); + migrateVersionTwoToThree(config); + migrateVersionThreeToFour(config); + migrateVersionFourToFive(config); + config.set(SCHEMA_VERSION_KEY, CURRENT_SCHEMA_VERSION); + + try { + config.save(file); + } catch (IOException exception) { + ChatHelper.logError("Could not migrate rail types in %s.", exception, FILE_PATH); + } + } + + private void migrateVersionOneToTwo(YamlConfiguration config) { + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) { + ConfigurationSection section = typesSection.getConfigurationSection(identifier); + + if (section == null) + continue; + + setDefault(section, KEY_OVERHEAD_POLES_ENABLED, false); + setDefault(section, KEY_OVERHEAD_POLE_BLOCK, XMaterial.LIGHT_GRAY_CONCRETE.name()); + setDefault(section, KEY_OVERHEAD_POLE_SPACING, RailType.DEFAULT_OVERHEAD_POLE_SPACING); + setDefault(section, KEY_OVERHEAD_POLE_OFFSET, RailType.DEFAULT_OVERHEAD_POLE_OFFSET); + setDefault(section, KEY_OVERHEAD_POLE_HEIGHT, RailType.DEFAULT_OVERHEAD_POLE_HEIGHT); + setDefault(section, KEY_OVERHEAD_WIRES_ENABLED, false); + setDefault(section, KEY_OVERHEAD_WIRE_BLOCK, XMaterial.IRON_BARS.name()); + setDefault(section, KEY_TRACK_SWITCHES_ENABLED, false); + } + } + + private void migrateVersionTwoToThree(YamlConfiguration config) { + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) { + ConfigurationSection section = typesSection.getConfigurationSection(identifier); + + if (section == null) + continue; + + setDefault(section, KEY_OVERHEAD_SUPPORT_BLOCK, section.getString( + KEY_OVERHEAD_POLE_BLOCK, + XMaterial.LIGHT_GRAY_CONCRETE.name() + )); + } + } + + private void migrateVersionThreeToFour(YamlConfiguration config) { + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) { + ConfigurationSection section = typesSection.getConfigurationSection(identifier); + + if (section == null || section.contains(KEY_TRACK_SPACINGS)) + continue; + + int trackCount = section.getInt(KEY_TRACK_COUNT, RailType.DEFAULT_TRACK_COUNT); + int trackSpacing = section.getInt(KEY_TRACK_SPACING, RailType.DEFAULT_TRACK_SPACING); + section.set(KEY_TRACK_SPACINGS, Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing)); + } + } + + static List parsePalette(Object value) { + if (value == null) + return List.of(); + List values = value instanceof List list ? list : List.of(value); + List materials = new ArrayList<>(); + for (Object entry : values) + materials.add(entry instanceof String name ? XMaterial.matchXMaterial(name).orElse(null) : null); + return materials; + } + + static void migrateVersionFourToFive(YamlConfiguration config) { + ConfigurationSection types = config.getConfigurationSection(TYPES_SECTION); + if (types == null) + return; + for (String identifier : types.getKeys(false)) { + ConfigurationSection section = types.getConfigurationSection(identifier); + if (section == null) + continue; + for (String key : List.of(KEY_RAIL_BLOCK, KEY_SLEEPER_BLOCK, KEY_OVERHEAD_POLE_BLOCK, + KEY_OVERHEAD_SUPPORT_BLOCK, KEY_OVERHEAD_WIRE_BLOCK)) { + Object value = section.get(key); + if (value instanceof String) + section.set(key, List.of(value)); + } + } + } + private void setDefault(ConfigurationSection section, String path, Object value) { + if (!section.contains(path)) + section.set(path, value); + } + + static RailType.Configuration toConfiguration(RailType railType) { + return new RailType.Configuration() + .identifier(railType.getIdentifier()) + .displayName(railType.getDisplayName()) + .icon(railType.getIcon()) + .railBlocks(railType.getRailBlocks()) + .blocksBelow(railType.getBlocksBelow()) + .sleeperBlocks(railType.getSleeperBlocks()) + .sleeperSpacing(railType.getSleeperSpacing()) + .trackCount(railType.getTrackCount()) + .trackSpacing(railType.getTrackSpacing()) + .trackSpacings(railType.getTrackSpacings()) + .overheadPolesEnabled(railType.isOverheadPolesEnabled()) + .overheadPoleBlocks(railType.getOverheadPoleBlocks()) + .overheadSupportBlocks(railType.getOverheadSupportBlocks()) + .overheadPoleSpacing(railType.getOverheadPoleSpacing()) + .overheadPoleOffset(railType.getOverheadPoleOffset()) + .overheadPoleHeight(railType.getOverheadPoleHeight()) + .overheadWiresEnabled(railType.isOverheadWiresEnabled()) + .overheadWireBlocks(railType.getOverheadWireBlocks()) + .trackSwitchesEnabled(railType.isTrackSwitchesEnabled()); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/PositionKey.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/PositionKey.java similarity index 95% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/PositionKey.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/PositionKey.java index c2dbd955..196433c0 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/PositionKey.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/PositionKey.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import org.bukkit.util.Vector; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java new file mode 100644 index 00000000..d71a54eb --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java @@ -0,0 +1,485 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import com.fastasyncworldedit.core.registry.state.PropertyKey; +import com.sk89q.worldedit.util.Direction; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockType; +import com.sk89q.worldedit.world.block.BlockTypes; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import org.bukkit.util.Vector; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class RailBlockBuilder { + + private static final Direction DEFAULT_FACING = Direction.EAST; + private static final String FACING_PROPERTY = "facing"; + private static final String SHAPE_PROPERTY = "shape"; + private static final int FACING_SMOOTHING_RADIUS = 3; + // Sleeper ends stick out one block beyond the rails, which sit at offset 1 from the track center. + private static final int SLEEPER_SIDE_OFFSET = 2; + + private final RailTerrainResolver terrainResolver; + private final RailType railType; + private final List railBlockTypes; + private final RailPreparationProgress preparationProgress; + private final long terrainAdjustedPercentage; + private final long buildFinishedPercentage; + + RailBlockBuilder( + RailTerrainResolver terrainResolver, + RailType railType, + RailPreparationProgress preparationProgress, + long terrainAdjustedPercentage, + long buildFinishedPercentage + ) { + this.terrainResolver = terrainResolver; + this.railType = railType; + this.railBlockTypes = railType.getRailBlocks().stream().map(this::getRailBlockType).toList(); + this.preparationProgress = preparationProgress; + this.terrainAdjustedPercentage = terrainAdjustedPercentage; + this.buildFinishedPercentage = buildFinishedPercentage; + } + + Map build(List> railCenterPaths) { + Map railBlocks = new LinkedHashMap<>(); + Set centerPositions = getCenterPositions(railCenterPaths); + Map sideBlocks = new LinkedHashMap<>(); + int totalPathPoints = getTotalPathPointCount(railCenterPaths); + int processedPathPoints = 0; + + for (List railCenterPath : railCenterPaths) { + for (int index = 0; index < railCenterPath.size(); index++) { + Vector center = railCenterPath.get(index); + Direction facing = getSmoothedFacing(railCenterPath, index); + + for (RailSidePlacement sidePlacement : getSidePlacements(railCenterPath, index, facing)) + addSideBlock(sideBlocks, center, sidePlacement, centerPositions); + + processedPathPoints++; + preparationProgress.update(preparationProgress.scale(processedPathPoints, totalPathPoints, terrainAdjustedPercentage, 86L)); + } + } + + int processedSideBlocks = 0; + + for (RailSideBlock sideBlock : sideBlocks.values()) { + railBlocks.put(sideBlock.key(), createRailBlockState(sideBlock, sideBlocks)); + processedSideBlocks++; + preparationProgress.update(preparationProgress.scale(processedSideBlocks, sideBlocks.size(), 86L, 89L)); + } + + if (railType.hasSleepers()) + for (List railCenterPath : railCenterPaths) + addSleeperBlocks(railBlocks, railCenterPath, centerPositions); + + int processedCenterPoints = 0; + + for (List railCenterPath : railCenterPaths) { + for (int index = 0; index < railCenterPath.size(); index++) { + Vector center = railCenterPath.get(index); + railBlocks.put(PositionKey.from(center), createCenterBlockState(center, isSleeperPoint(index))); + processedCenterPoints++; + preparationProgress.update(preparationProgress.scale(processedCenterPoints, totalPathPoints, 89L, buildFinishedPercentage)); + } + } + + return railBlocks; + } + + private boolean isSleeperPoint(int index) { + return railType.hasSleepers() && index % railType.getSleeperSpacing() == 0; + } + + private void addSleeperBlocks(Map railBlocks, List path, Set centerPositions) { + for (int index = 0; index < path.size(); index++) { + if (!isSleeperPoint(index)) + continue; + + Vector center = path.get(index); + RailStep step = getRailStep(path, index, new RailStep(1, 0)); + RailStep perpendicularStep = new RailStep(-step.dz(), step.dx()); + + addSleeperBlock(railBlocks, center, perpendicularStep, SLEEPER_SIDE_OFFSET, centerPositions); + addSleeperBlock(railBlocks, center, perpendicularStep, -SLEEPER_SIDE_OFFSET, centerPositions); + } + } + + private void addSleeperBlock( + Map railBlocks, + Vector center, + RailStep perpendicularStep, + int offset, + Set centerPositions + ) { + if (perpendicularStep.dx() == 0 && perpendicularStep.dz() == 0) + return; + + int x = center.getBlockX() + perpendicularStep.dx() * offset; + int z = center.getBlockZ() + perpendicularStep.dz() * offset; + int y = terrainResolver.getNearestRailSurfaceY(x, z, center.getBlockY()); + + PositionKey key = PositionKey.of(x, y, z); + + // Rails and track centers take precedence over sleeper ends. + if (centerPositions.contains(key) || railBlocks.containsKey(key)) + return; + + railBlocks.put(key, GeneratorUtils.getBlockState(RailMaterialPalette.select(railType.getSleeperBlocks(), key))); + } + + private int getTotalPathPointCount(List> railCenterPaths) { + int totalPathPoints = 0; + + for (List railCenterPath : railCenterPaths) + totalPathPoints += railCenterPath.size(); + + return Math.max(1, totalPathPoints); + } + + private static List getSidePlacements(List path, int index, Direction facing) { + List placements = new ArrayList<>(); + Vector center = path.get(index); + RailStep previousStep = index > 0 ? getStep(path.get(index - 1), center) : null; + RailStep nextStep = index < path.size() - 1 ? getStep(center, path.get(index + 1)) : null; + + addSidePlacements(placements, getRailStep(path, index, new RailStep(1, 0)), facing); + + if (previousStep != null) + addSidePlacements(placements, previousStep, facing); + + if (nextStep != null) + addSidePlacements(placements, nextStep, facing); + + return placements; + } + + private static void addSidePlacements(List placements, RailStep step, Direction facing) { + if (step.dx() != 0 && step.dz() != 0) { + addSidePlacement(placements, new RailStep(step.dx(), 0), facing); + addSidePlacement(placements, new RailStep(0, step.dz()), facing); + return; + } + + if (step.dx() != 0) { + addSidePlacement(placements, new RailStep(0, 1), facing); + addSidePlacement(placements, new RailStep(0, -1), facing); + return; + } + + addSidePlacement(placements, new RailStep(1, 0), facing); + addSidePlacement(placements, new RailStep(-1, 0), facing); + } + + private static Direction getSmoothedFacing(List path, int index) { + int fromIndex = Math.max(0, index - FACING_SMOOTHING_RADIUS); + int toIndex = Math.min(path.size() - 1, index + FACING_SMOOTHING_RADIUS); + Vector from = path.get(fromIndex); + Vector to = path.get(toIndex); + int dx = to.getBlockX() - from.getBlockX(); + int dz = to.getBlockZ() - from.getBlockZ(); + + if (Math.abs(dx) == Math.abs(dz)) { + Vector pathStart = path.getFirst(); + Vector pathEnd = path.getLast(); + int totalDx = pathEnd.getBlockX() - pathStart.getBlockX(); + int totalDz = pathEnd.getBlockZ() - pathStart.getBlockZ(); + + if (Math.abs(totalDx) != Math.abs(totalDz)) { + dx = totalDx; + dz = totalDz; + } + } + + if (Math.abs(dx) >= Math.abs(dz) && dx != 0) + return GeneratorUtils.getFacing(Integer.compare(dx, 0), 0, DEFAULT_FACING); + + if (dz != 0) + return GeneratorUtils.getFacing(0, Integer.compare(dz, 0), DEFAULT_FACING); + + return DEFAULT_FACING; + } + + private static void addSidePlacement(List placements, RailStep offset, Direction facing) { + for (RailSidePlacement placement : placements) { + if (placement.offset().equals(offset)) return; + } + + placements.add(new RailSidePlacement(offset, facing)); + } + + private void addSideBlock( + Map sideBlocks, + Vector center, + RailSidePlacement sidePlacement, + Set centerPositions + ) { + RailStep sideOffset = sidePlacement.offset(); + + if (sideOffset.dx() == 0 && sideOffset.dz() == 0) + return; + + int x = center.getBlockX() + sideOffset.dx(); + int z = center.getBlockZ() + sideOffset.dz(); + int y = terrainResolver.getNearestRailSurfaceY(x, z, center.getBlockY()); + + PositionKey key = PositionKey.of(x, y, z); + + if (centerPositions.contains(key)) + return; + + sideBlocks + .computeIfAbsent(key, ignored -> new RailSideBlock(key, DEFAULT_FACING)) + .addFacing(sidePlacement.facing()); + } + + private Direction resolveSideBlockFacing(RailSideBlock sideBlock, Map sideBlocks) { + RailConnections connections = getConnections(sideBlock.key(), sideBlocks); + int xConnections = (connections.east() ? 1 : 0) + (connections.west() ? 1 : 0); + int zConnections = (connections.south() ? 1 : 0) + (connections.north() ? 1 : 0); + Direction preferredFacing = sideBlock.getPreferredFacing(); + + if (xConnections > zConnections) + return resolveAxisFacing( + preferredFacing, + Direction.EAST, + Direction.WEST, + connections.east(), + connections.west() + ); + + if (zConnections > xConnections) + return resolveAxisFacing( + preferredFacing, + Direction.SOUTH, + Direction.NORTH, + connections.south(), + connections.north() + ); + + return preferredFacing; + } + + private Direction resolveAxisFacing( + Direction preferredFacing, + Direction positiveFacing, + Direction negativeFacing, + boolean hasPositiveNeighbor, + boolean hasNegativeNeighbor + ) { + if (preferredFacing == positiveFacing && hasPositiveNeighbor || preferredFacing == negativeFacing && hasNegativeNeighbor) + return preferredFacing; + + if (hasPositiveNeighbor && !hasNegativeNeighbor) + return positiveFacing; + + if (hasNegativeNeighbor && !hasPositiveNeighbor) + return negativeFacing; + + return preferredFacing == negativeFacing ? negativeFacing : positiveFacing; + } + + private Set getCenterPositions(List> railCenterPaths) { + Set centerPositions = new HashSet<>(); + + for (List railCenterPath : railCenterPaths) + for (Vector center : railCenterPath) + centerPositions.add(PositionKey.from(center)); + + return centerPositions; + } + + private static RailStep getRailStep(List path, int index, RailStep fallbackStep) { + RailStep previousStep = index > 0 ? getStep(path.get(index - 1), path.get(index)) : null; + RailStep nextStep = index < path.size() - 1 ? getStep(path.get(index), path.get(index + 1)) : null; + + if (previousStep != null && nextStep != null) { + int dx = Integer.compare(previousStep.dx() + nextStep.dx(), 0); + int dz = Integer.compare(previousStep.dz() + nextStep.dz(), 0); + + if (dx != 0 || dz != 0) return new RailStep(dx, dz); + } + + if (nextStep != null) return nextStep; + + if (previousStep != null) return previousStep; + + return fallbackStep; + } + + private static RailStep getStep(Vector from, Vector to) { + int dx = Integer.compare(to.getBlockX() - from.getBlockX(), 0); + int dz = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); + + if (dx == 0 && dz == 0) return null; + + return new RailStep(dx, dz); + } + + private BlockState createCenterBlockState(Vector position, boolean sleeperPoint) { + if (sleeperPoint) + return GeneratorUtils.getBlockState(RailMaterialPalette.select(railType.getSleeperBlocks(), PositionKey.from(position))); + + List blocksBelow = railType.getBlocksBelow(); + int index = Math.floorMod( + position.getBlockX() * 31 + position.getBlockY() * 23 + position.getBlockZ() * 17, + blocksBelow.size() + ); + + return GeneratorUtils.getBlockState(blocksBelow.get(index)); + } + + private BlockState createRailBlockState( + RailSideBlock sideBlock, + Map sideBlocks + ) { + BlockType railBlockType = RailMaterialPalette.select(railBlockTypes, sideBlock.key()); + Direction preferredDirection = sideBlock.getPreferredFacing(); + + if (railBlockType.getPropertyMap().containsKey(SHAPE_PROPERTY)) + return createRailShapeBlockState(railBlockType, + resolveSideBlockFacing(sideBlock, sideBlocks), + getConnections(sideBlock.key(), sideBlocks) + ); + + if (!railBlockType.getPropertyMap().containsKey(FACING_PROPERTY)) + return railBlockType.getDefaultState(); + + return GeneratorUtils.getBlockStateWithFacing(railBlockType, preferredDirection); + } + + private BlockState createRailShapeBlockState(BlockType railBlockType, Direction preferredDirection, RailConnections connections) { + String shape = resolveRailShape(preferredDirection, connections); + + try { + return railBlockType.getDefaultState().with( + PropertyKey.SHAPE, + shape + ); + } catch (IllegalArgumentException exception) { + return railBlockType.getDefaultState().with( + PropertyKey.SHAPE, + getStraightRailShape(preferredDirection) + ); + } + } + + private String resolveRailShape(Direction preferredDirection, RailConnections connections) { + String ascendingShape = getAscendingRailShape(connections); + + if (ascendingShape != null) + return ascendingShape; + + String cornerShape = getCornerRailShape(connections); + + if (cornerShape != null) + return cornerShape; + + return getAxisRailShape(preferredDirection, connections); + } + + private @Nullable String getAscendingRailShape(RailConnections connections) { + if (isRaised(connections.eastHeightDifference())) + return "ascending_east"; + + if (isRaised(connections.westHeightDifference())) + return "ascending_west"; + + if (isRaised(connections.southHeightDifference())) + return "ascending_south"; + + if (isRaised(connections.northHeightDifference())) + return "ascending_north"; + + return null; + } + + private @Nullable String getCornerRailShape(RailConnections connections) { + if (connections.connectionCount() != 2) + return null; + + if (connections.north() && connections.east()) + return "north_east"; + + if (connections.north() && connections.west()) + return "north_west"; + + if (connections.south() && connections.east()) + return "south_east"; + + return connections.south() && connections.west() ? "south_west" : null; + } + + private String getAxisRailShape(Direction preferredDirection, RailConnections connections) { + int xConnections = (connections.east() ? 1 : 0) + (connections.west() ? 1 : 0); + int zConnections = (connections.south() ? 1 : 0) + (connections.north() ? 1 : 0); + + if (xConnections > zConnections) + return "east_west"; + + if (zConnections > xConnections) + return "north_south"; + + return getStraightRailShape(preferredDirection); + } + + private String getStraightRailShape(Direction direction) { + return direction == Direction.NORTH || direction == Direction.SOUTH ? "north_south" : "east_west"; + } + + private boolean isRaised(@Nullable Integer heightDifference) { + return heightDifference != null && heightDifference > 0; + } + + private RailConnections getConnections( + PositionKey key, + Map sideBlocks + ) { + return new RailConnections( + getNeighborHeightDifference(key, 1, 0, sideBlocks), + getNeighborHeightDifference(key, -1, 0, sideBlocks), + getNeighborHeightDifference(key, 0, 1, sideBlocks), + getNeighborHeightDifference(key, 0, -1, sideBlocks) + ); + } + + private @Nullable Integer getNeighborHeightDifference( + PositionKey key, + int xOffset, + int zOffset, + Map sideBlocks + ) { + for (int heightDifference = 0; heightDifference <= 1; heightDifference++) { + if (sideBlocks.containsKey(PositionKey.of( + key.x() + xOffset, + key.y() + heightDifference, + key.z() + zOffset + ))) + return heightDifference; + + if (heightDifference > 0 && sideBlocks.containsKey(PositionKey.of( + key.x() + xOffset, + key.y() - heightDifference, + key.z() + zOffset + ))) + return -heightDifference; + } + + return null; + } + + private BlockType getRailBlockType(XMaterial material) { + BlockType blockType = Item.convertXMaterialToWEBlockType(material); + + return blockType == null ? BlockTypes.ANVIL : blockType; + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailColumnKey.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailColumnKey.java similarity index 89% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailColumnKey.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailColumnKey.java index ec459ab1..c57a6f97 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailColumnKey.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailColumnKey.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; record RailColumnKey(int x, int z) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java new file mode 100644 index 00000000..dcadff9d --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java @@ -0,0 +1,31 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import org.jspecify.annotations.Nullable; + +record RailConnections( + @Nullable Integer eastHeightDifference, + @Nullable Integer westHeightDifference, + @Nullable Integer southHeightDifference, + @Nullable Integer northHeightDifference +) { + + boolean east() { + return eastHeightDifference != null; + } + + boolean west() { + return westHeightDifference != null; + } + + boolean south() { + return southHeightDifference != null; + } + + boolean north() { + return northHeightDifference != null; + } + + int connectionCount() { + return (east() ? 1 : 0) + (west() ? 1 : 0) + (south() ? 1 : 0) + (north() ? 1 : 0); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java new file mode 100644 index 00000000..72c05da5 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java @@ -0,0 +1,166 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import com.alpsbte.alpslib.utils.GeneratorUtils; +import org.bukkit.util.Vector; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class RailLanePathBuilder { + + private final List controlPoints; + private final RailTerrainResolver terrainResolver; + private final int railLaneCount; + private final List railLaneSpacings; + + RailLanePathBuilder( + List controlPoints, + RailTerrainResolver terrainResolver, + int railLaneCount, + List railLaneSpacings + ) { + this.controlPoints = controlPoints; + this.terrainResolver = terrainResolver; + this.railLaneCount = railLaneCount; + this.railLaneSpacings = railLaneSpacings; + } + + List> createRailCenterPaths(List path) { + if (railLaneCount <= 1) + return List.of(path); + + List> railCenterPaths = new ArrayList<>(); + List offsets = createLaneOffsets(); + + // Lanes are spread symmetrically around the selected path. Even track counts + // have no lane on the path itself, only shifted lanes on both sides. + for (int laneIndex = 0; laneIndex < railLaneCount; laneIndex++) { + int offset = offsets.get(laneIndex); + + if (offset == 0) { + railCenterPaths.add(path); + continue; + } + + List shiftedLane = createShiftedRailLane(Math.abs(offset), offset > 0 ? 1 : -1); + + if (shiftedLane.size() >= 2) + railCenterPaths.add(shiftedLane); + } + + return railCenterPaths; + } + + private List createLaneOffsets() { + List positions = new ArrayList<>(railLaneCount); + int position = 0; + positions.add(position); + + for (int spacing : railLaneSpacings) { + position += spacing; + positions.add(position); + } + + double center = position / 2.0D; + return positions.stream().map(value -> (int) Math.round(value - center)).toList(); + } + + private List createShiftedRailLane(int distance, int sideSign) { + List shiftedControlPoints = createShiftedControlPoints(distance, sideSign); + + if (shiftedControlPoints.size() < 2) + return Collections.emptyList(); + + List shiftedPath = createCenterPath(shiftedControlPoints); + + if (shiftedPath.size() < 2) + return Collections.emptyList(); + + terrainResolver.adjustPathToTerrain(shiftedPath); + return shiftedPath; + } + + private List createShiftedControlPoints(int distance, int sideSign) { + List shiftedControlPoints = new ArrayList<>(); + + for (int index = 0; index < controlPoints.size(); index++) { + Vector basePoint = controlPoints.get(index); + Vector offset = getMiterOffset(index, distance, sideSign); + + if (offset.lengthSquared() != 0) + addIfDifferentFromPrevious(shiftedControlPoints, new Vector( + basePoint.getBlockX() + (int) Math.round(offset.getX()), + basePoint.getBlockY(), + basePoint.getBlockZ() + (int) Math.round(offset.getZ()) + )); + } + + return shiftedControlPoints; + } + + private Vector getMiterOffset(int index, int distance, int sideSign) { + Vector previousDirection = index > 0 + ? normalizedHorizontalDirection(controlPoints.get(index - 1), controlPoints.get(index)) + : new Vector(); + Vector nextDirection = index < controlPoints.size() - 1 + ? normalizedHorizontalDirection(controlPoints.get(index), controlPoints.get(index + 1)) + : new Vector(); + + if (previousDirection.lengthSquared() == 0) + return perpendicular(nextDirection, sideSign).multiply(distance); + + if (nextDirection.lengthSquared() == 0) + return perpendicular(previousDirection, sideSign).multiply(distance); + + Vector previousNormal = perpendicular(previousDirection, sideSign); + Vector nextNormal = perpendicular(nextDirection, sideSign); + Vector miter = previousNormal.clone().add(nextNormal); + + if (miter.lengthSquared() < 0.0001D) + return nextNormal.multiply(distance); + + miter.normalize(); + double denominator = miter.dot(nextNormal); + + if (Math.abs(denominator) < 0.25D) + return nextNormal.multiply(distance); + + // The miter keeps both adjacent segments at the requested perpendicular + // distance. Limit extreme spikes at very sharp control-point corners. + double miterLength = Math.min(distance / denominator, distance * 4.0D); + return miter.multiply(miterLength); + } + + private Vector normalizedHorizontalDirection(Vector from, Vector to) { + Vector direction = new Vector( + to.getBlockX() - from.getBlockX(), + 0, + to.getBlockZ() - from.getBlockZ() + ); + + return direction.lengthSquared() == 0 ? direction : direction.normalize(); + } + + private Vector perpendicular(Vector direction, int sideSign) { + return new Vector(-direction.getZ() * sideSign, 0, direction.getX() * sideSign); + } + + private void addIfDifferentFromPrevious(List points, Vector point) { + if (points.isEmpty()) { + points.add(point); + return; + } + + Vector previousPoint = points.get(points.size() - 1); + + if (previousPoint.getBlockX() == point.getBlockX() && previousPoint.getBlockZ() == point.getBlockZ()) + return; + + points.add(point); + } + + private List createCenterPath(List points) { + return GeneratorUtils.removeOrthogonalCorners(GeneratorUtils.createShortestBlockPath(points)); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLimits.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLimits.java similarity index 54% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLimits.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLimits.java index 860fd0ae..e051f902 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLimits.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLimits.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.utils.io.ConfigPaths; @@ -15,15 +15,15 @@ record RailLimits( ) { // Default amount of WorldEdit control points accepted before path interpolation starts. - private static final int DEFAULT_MAX_CONTROL_POINTS = 1_000; + private static final int DEFAULT_MAX_CONTROL_POINTS = 2_000; // Default amount of interpolated center-path blocks a rail may contain. - private static final int DEFAULT_MAX_PATH_POINTS = 24_000; + private static final int DEFAULT_MAX_PATH_POINTS = 75_000; // Default amount of final center, side and support blocks queued for placement. - private static final int DEFAULT_MAX_BLOCK_PLACEMENTS = 150_000; + private static final int DEFAULT_MAX_BLOCK_PLACEMENTS = 300_000; // Default volume of the prepared terrain lookup region around the selected rail path. - private static final long DEFAULT_MAX_PREPARED_REGION_VOLUME = 1_500_000L; + private static final long DEFAULT_MAX_PREPARED_REGION_VOLUME = 6_000_000L; // Default maximum width, height or depth of the prepared terrain lookup region. - private static final int DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH = 1_024; + private static final int DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH = 2_048; // Default amount of block changes applied per scheduler tick during execution. private static final int DEFAULT_BLOCK_PLACEMENT_BATCH_SIZE = 750; @@ -44,20 +44,56 @@ static RailLimits fromConfig() { FileConfiguration config = BuildTeamTools.getInstance().getConfig(ConfigUtil.GENERATOR); return new RailLimits( - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_CONTROL_POINTS, DEFAULT_MAX_CONTROL_POINTS, 2, MAX_CONTROL_POINTS), - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_PATH_POINTS, DEFAULT_MAX_PATH_POINTS, 2, MAX_PATH_POINTS), - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_BLOCK_PLACEMENTS, DEFAULT_MAX_BLOCK_PLACEMENTS, 1, MAX_BLOCK_PLACEMENTS), - getBoundedLong(config, ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_VOLUME, DEFAULT_MAX_PREPARED_REGION_VOLUME, 1L, MAX_PREPARED_REGION_VOLUME), - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_AXIS_LENGTH, DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH, 1, MAX_PREPARED_REGION_AXIS_LENGTH), - getBoundedInt(config, ConfigPaths.Generator.Rail.BLOCK_PLACEMENT_BATCH_SIZE, DEFAULT_BLOCK_PLACEMENT_BATCH_SIZE, 1, MAX_BLOCK_PLACEMENT_BATCH_SIZE) + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_CONTROL_POINTS, + DEFAULT_MAX_CONTROL_POINTS, + 2, + MAX_CONTROL_POINTS + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_PATH_POINTS, + DEFAULT_MAX_PATH_POINTS, + 2, + MAX_PATH_POINTS + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_BLOCK_PLACEMENTS, + DEFAULT_MAX_BLOCK_PLACEMENTS, + 1, + MAX_BLOCK_PLACEMENTS + ), + getBoundedLong( + config, + ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_VOLUME, + DEFAULT_MAX_PREPARED_REGION_VOLUME, + 1L, + MAX_PREPARED_REGION_VOLUME + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_AXIS_LENGTH, + DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH, + 1, + MAX_PREPARED_REGION_AXIS_LENGTH + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.BLOCK_PLACEMENT_BATCH_SIZE, + DEFAULT_BLOCK_PLACEMENT_BATCH_SIZE, + 1, + MAX_BLOCK_PLACEMENT_BATCH_SIZE + ) ); } private static int getBoundedInt(FileConfiguration config, String path, int fallback, int minimum, int maximum) { - return Math.max(minimum, Math.min(maximum, config.getInt(path, fallback))); + return Math.clamp(config.getInt(path, fallback), minimum, maximum); } private static long getBoundedLong(FileConfiguration config, String path, long fallback, long minimum, long maximum) { - return Math.max(minimum, Math.min(maximum, config.getLong(path, fallback))); + return Math.clamp(config.getLong(path, fallback), minimum, maximum); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java new file mode 100644 index 00000000..c36b170d --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java @@ -0,0 +1,22 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import java.util.List; + +/** Stable spatial mixing: a repeated generation picks the same material at each position. */ +final class RailMaterialPalette { + private RailMaterialPalette() { + } + + static T select(List materials, int x, int y, int z) { + if (materials.isEmpty()) + throw new IllegalArgumentException("A material palette must not be empty."); + int hash = x * 73428767 ^ y * 912931 ^ z * 438289; + hash = (hash ^ (hash >>> 16)) * 0x45d9f3b; + hash ^= hash >>> 16; + return materials.get(Math.floorMod(hash, materials.size())); + } + + static T select(List materials, PositionKey position) { + return select(materials, position.x(), position.y(), position.z()); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java new file mode 100644 index 00000000..f47c3609 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java @@ -0,0 +1,380 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.alpsbte.alpslib.utils.item.Item; +import com.fastasyncworldedit.core.registry.state.PropertyKey; +import com.sk89q.worldedit.util.Direction; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import org.bukkit.util.Vector; +import org.jspecify.annotations.Nullable; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class RailOverheadBuilder { + + private static final Direction DEFAULT_FACING = Direction.EAST; + private static final String FACING_PROPERTY = "facing"; + private static final int MAX_POLE_COLLISION_SHIFTS = RailType.MAX_TRACK_COUNT * RailType.MAX_TRACK_SPACING; + + private final RailTerrainResolver terrainResolver; + private final RailType railType; + + RailOverheadBuilder(RailTerrainResolver terrainResolver, RailType railType) { + this.terrainResolver = terrainResolver; + this.railType = railType; + } + + void addTo(Map blocks, List> railCenterPaths) { + int overheadY = getOverheadY(railCenterPaths); + Set trackFootprint = getHorizontalFootprint(blocks.keySet()); + + if (railType.hasOverheadWires()) + addWires(blocks, railCenterPaths, overheadY); + + if (!railType.hasOverheadPoles() || railCenterPaths.isEmpty()) + return; + + addPortals( + blocks, + railCenterPaths.getFirst(), + railCenterPaths.getLast(), + overheadY, + trackFootprint + ); + } + + private Set getHorizontalFootprint(Set positions) { + Set footprint = new HashSet<>(); + + for (PositionKey position : positions) + footprint.add(new HorizontalPosition(position.x(), position.z())); + + return footprint; + } + + private int getOverheadY(List> railCenterPaths) { + int maxRailY = Integer.MIN_VALUE; + + for (List path : railCenterPaths) + for (Vector point : path) + maxRailY = Math.max(maxRailY, point.getBlockY()); + + return (maxRailY == Integer.MIN_VALUE ? 0 : maxRailY) + railType.getOverheadPoleHeight(); + } + + private void addWires(Map blocks, List> railCenterPaths, int overheadY) { + List wireBlockTypes = railType.getOverheadWireBlocks().stream().map(Item::convertXMaterialToWEBlockType).toList(); + + for (List path : railCenterPaths) { + List wirePath = createOverheadPath(path, overheadY); + addPathBlocks(blocks, wirePath, wireBlockTypes, false); + } + } + + private void addPortals( + Map blocks, + List leftPath, + List rightPath, + int overheadY, + Set trackFootprint + ) { + List supportBlockTypes = railType.getOverheadSupportBlocks().stream().map(Item::convertXMaterialToWEBlockType).toList(); + + for (int index = 0; index < leftPath.size(); index += railType.getOverheadPoleSpacing()) { + Vector leftCenter = leftPath.get(index); + int rightIndex = getProportionalIndex(index, leftPath.size(), rightPath.size()); + Vector rightCenter = rightPath.get(rightIndex); + PortalPole leftPole = createPole(leftPath, index, leftCenter, -1, trackFootprint); + PortalPole rightPole = createPole(rightPath, rightIndex, rightCenter, 1, trackFootprint); + + if (leftPole == null || rightPole == null) + continue; + + addPole(blocks, leftPole.x(), leftPole.z(), leftPole.surfaceY(), overheadY); + addPole(blocks, rightPole.x(), rightPole.z(), rightPole.surfaceY(), overheadY); + addPortalSupport(blocks, leftPole.x(), overheadY + 1, leftPole.z(), rightPole.x(), rightPole.z(), supportBlockTypes); + } + } + + private int getProportionalIndex(int sourceIndex, int sourceSize, int targetSize) { + if (sourceSize <= 1 || targetSize <= 1) + return 0; + + double progress = sourceIndex / (double) (sourceSize - 1); + return Math.clamp((int) Math.round(progress * (targetSize - 1)), 0, targetSize - 1); + } + + private @Nullable PortalPole createPole( + List path, + int index, + Vector center, + int sideSign, + Set trackFootprint + ) { + RailStep perpendicular = getPerpendicularStep(path, index, sideSign); + int configuredDistance = railType.getOverheadPoleOffset(); + + for (int shift = 0; shift <= MAX_POLE_COLLISION_SHIFTS; shift++) { + int distance = configuredDistance + shift; + int poleX = center.getBlockX() + perpendicular.dx() * distance; + int poleZ = center.getBlockZ() + perpendicular.dz() * distance; + + if (trackFootprint.contains(new HorizontalPosition(poleX, poleZ))) + continue; + + int surfaceY = terrainResolver.getNearestRailSurfaceY(poleX, poleZ, center.getBlockY()); + return new PortalPole(poleX, poleZ, surfaceY); + } + + return null; + } + + private void addPole( + Map blocks, + int poleX, + int poleZ, + int surfaceY, + int topY + ) { + for (int y = Math.min(surfaceY, topY); y <= topY; y++) { + PositionKey position = PositionKey.of(poleX, y, poleZ); + blocks.putIfAbsent(position, GeneratorUtils.getBlockState( + RailMaterialPalette.select(railType.getOverheadPoleBlocks(), position))); + } + } + + private void addPortalSupport( + Map blocks, + int startX, + int topY, + int startZ, + int endX, + int endZ, + List supportBlockTypes + ) { + Vector start = new Vector(startX, topY, startZ); + Vector end = new Vector(endX, topY, endZ); + addPathBlocks(blocks, createOrthogonalPath(start, end), supportBlockTypes, true); + } + + private RailStep getPerpendicularStep(List path, int index, int sideSign) { + RailStep direction = getStep(path, index); + return new RailStep(-direction.dz() * sideSign, direction.dx() * sideSign); + } + + private List createOverheadPath(List path, int overheadY) { + List overheadPoints = path.stream() + .map(point -> new Vector(point.getBlockX(), overheadY, point.getBlockZ())) + .toList(); + + return GeneratorUtils.createShortestBlockPath(overheadPoints); + } + + private List createOrthogonalPath(Vector start, Vector end) { + return GeneratorUtils.createShortestBlockPath(List.of(start, end)); + } + + private void addPathBlocks( + Map blocks, + List path, + List blockTypes, + boolean overwrite + ) { + Set positions = createConnectedPathPositions(path); + + for (PositionKey position : positions) { + BlockType blockType = RailMaterialPalette.select(blockTypes, position); + BlockState blockState = createPathState(blockType, position, positions); + + if (overwrite) + blocks.put(position, blockState); + else + blocks.computeIfAbsent(position, ignored -> blockState); + } + } + + private Set createConnectedPathPositions(List path) { + Set positions = new LinkedHashSet<>(); + + for (Vector point : path) + positions.add(PositionKey.from(point)); + + addDiagonalConnectorPositions(positions, path); + return positions; + } + + /** + * Minecraft blocks only connect over cardinal edges. The rasterizer also + * emits diagonal steps, so bridge each one with a single orthogonal block. + * Continuing the preceding/following cardinal direction avoids bulky 2x2 + * corners while keeping panes, bars, fences, walls and full blocks connected. + */ + private void addDiagonalConnectorPositions(Set positions, List path) { + for (int index = 1; index < path.size(); index++) { + Vector previous = path.get(index - 1); + Vector current = path.get(index); + RailStep step = getStepBetween(previous, current); + + if (step.dx() == 0 || step.dz() == 0) + continue; + + positions.add(PositionKey.from(selectDiagonalBridge(path, index, previous, current, step))); + } + } + + private Vector selectDiagonalBridge( + List path, + int index, + Vector previous, + Vector current, + RailStep diagonalStep + ) { + Vector xFirst = new Vector( + previous.getBlockX() + diagonalStep.dx(), + current.getBlockY(), + previous.getBlockZ() + ); + Vector zFirst = new Vector( + previous.getBlockX(), + current.getBlockY(), + previous.getBlockZ() + diagonalStep.dz() + ); + + if (index > 1) { + RailStep previousStep = getStepBetween(path.get(index - 2), previous); + + if (previousStep.dx() != 0 && previousStep.dz() == 0) + return xFirst; + + if (previousStep.dz() != 0 && previousStep.dx() == 0) + return zFirst; + } + + if (index < path.size() - 1) { + RailStep nextStep = getStepBetween(current, path.get(index + 1)); + + if (nextStep.dx() != 0 && nextStep.dz() == 0) + return zFirst; + + if (nextStep.dz() != 0 && nextStep.dx() == 0) + return xFirst; + } + + return index % 2 == 0 ? xFirst : zFirst; + } + + private BlockState createPathState(BlockType blockType, PositionKey position, Set positions) { + Set connections = EnumSet.noneOf(Direction.class); + addConnectionIfPresent(connections, positions, position, Direction.NORTH, 0, -1); + addConnectionIfPresent(connections, positions, position, Direction.EAST, 1, 0); + addConnectionIfPresent(connections, positions, position, Direction.SOUTH, 0, 1); + addConnectionIfPresent(connections, positions, position, Direction.WEST, -1, 0); + + if (connections.isEmpty()) + connections.add(DEFAULT_FACING); + + return createConnectedState(blockType, connections); + } + + private void addConnectionIfPresent( + Set connections, + Set positions, + PositionKey position, + Direction direction, + int xOffset, + int zOffset + ) { + if (positions.contains(PositionKey.of(position.x() + xOffset, position.y(), position.z() + zOffset))) + connections.add(direction); + } + + private BlockState createConnectedState(BlockType blockType, Set connections) { + BlockState blockState = blockType.getDefaultState(); + + if (!hasDirectionalConnectionProperties(blockType)) + return createDirectionalSupportState(blockType, connections.iterator().next()); + + blockState = applyConnection(blockState, blockType, PropertyKey.NORTH, connections.contains(Direction.NORTH)); + blockState = applyConnection(blockState, blockType, PropertyKey.EAST, connections.contains(Direction.EAST)); + blockState = applyConnection(blockState, blockType, PropertyKey.SOUTH, connections.contains(Direction.SOUTH)); + blockState = applyConnection(blockState, blockType, PropertyKey.WEST, connections.contains(Direction.WEST)); + + if (hasProperty(blockType, PropertyKey.UP)) + blockState = blockState.with(PropertyKey.UP, true); + + return blockState; + } + + private BlockState createDirectionalSupportState(BlockType blockType, Direction direction) { + BlockState blockState = createDirectionalState(blockType, direction); + + if (!hasProperty(blockType, PropertyKey.TYPE)) + return blockState; + + try { + return blockState.with(PropertyKey.TYPE, "bottom"); + } catch (IllegalArgumentException exception) { + return blockState; + } + } + + private BlockState applyConnection(BlockState blockState, BlockType blockType, PropertyKey key, boolean connected) { + if (!hasProperty(blockType, key)) + return blockState; + + try { + return blockState.with(key, connected); + } catch (IllegalArgumentException exception) { + return blockState.with(key, connected ? "low" : "none"); + } + } + + private boolean hasDirectionalConnectionProperties(BlockType blockType) { + return hasProperty(blockType, PropertyKey.NORTH) + || hasProperty(blockType, PropertyKey.EAST) + || hasProperty(blockType, PropertyKey.SOUTH) + || hasProperty(blockType, PropertyKey.WEST); + } + + private boolean hasProperty(BlockType blockType, PropertyKey key) { + return blockType.getPropertyMap().containsKey(key.getName()); + } + + private RailStep getStepBetween(Vector from, Vector to) { + int xDirection = Integer.compare(to.getBlockX() - from.getBlockX(), 0); + int zDirection = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); + return new RailStep(xDirection, zDirection); + } + + private RailStep getStep(List path, int index) { + Vector from = index > 0 ? path.get(index - 1) : path.get(index); + Vector to = index < path.size() - 1 ? path.get(index + 1) : path.get(index); + int xDirection = Integer.compare(to.getBlockX() - from.getBlockX(), 0); + int zDirection = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); + + if (xDirection == 0 && zDirection == 0) + return new RailStep(1, 0); + + return new RailStep(xDirection, zDirection); + } + + private BlockState createDirectionalState(BlockType blockType, Direction direction) { + if (!blockType.getPropertyMap().containsKey(FACING_PROPERTY)) + return blockType.getDefaultState(); + + return GeneratorUtils.getBlockStateWithFacing(blockType, direction); + } + + private record PortalPole(int x, int z, int surfaceY) { + } + + private record HorizontalPosition(int x, int z) { + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java new file mode 100644 index 00000000..af365883 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java @@ -0,0 +1,117 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import org.bukkit.util.Vector; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class RailPathOverlapValidator { + + private static final List VERTICAL_OFFSETS = List.of(-1, 0, 1); + + boolean hasOverlap(List> railCenterPaths) { + Set previousLanePositions = new HashSet<>(); + Set previousLaneDiagonals = new HashSet<>(); + + for (List railCenterPath : railCenterPaths) { + LaneGeometry currentLane = createLaneGeometry(railCenterPath); + + if (currentLane == null + || overlapsPreviousLane(currentLane, previousLanePositions, previousLaneDiagonals)) + return true; + + previousLanePositions.addAll(currentLane.centers()); + previousLaneDiagonals.addAll(currentLane.diagonals()); + } + + return false; + } + + private LaneGeometry createLaneGeometry(List railCenterPath) { + Set centers = new HashSet<>(); + Set diagonals = new HashSet<>(); + + for (int index = 0; index < railCenterPath.size(); index++) { + if (!centers.add(PositionKey.from(railCenterPath.get(index)))) + return null; + + if (index == 0) + continue; + + DiagonalStep diagonal = createDiagonalStep(railCenterPath.get(index - 1), railCenterPath.get(index)); + + if (diagonal != null && crossesDiagonal(diagonal, diagonals)) + return null; + + if (diagonal != null) + diagonals.add(diagonal); + } + + return new LaneGeometry(centers, diagonals); + } + + private boolean overlapsPreviousLane(LaneGeometry lane, + Set previousLanePositions, + Set previousLaneDiagonals) { + for (PositionKey center : lane.centers()) { + if (crossesPreviousLane(center, previousLanePositions)) + return true; + } + + for (DiagonalStep diagonal : lane.diagonals()) { + if (crossesDiagonal(diagonal, previousLaneDiagonals)) + return true; + } + + return false; + } + + private DiagonalStep createDiagonalStep(Vector from, Vector to) { + int dx = to.getBlockX() - from.getBlockX(); + int dz = to.getBlockZ() - from.getBlockZ(); + + if (Math.abs(dx) != 1 || Math.abs(dz) != 1) + return null; + + return new DiagonalStep( + Math.min(from.getBlockX(), to.getBlockX()), + Math.min(from.getBlockZ(), to.getBlockZ()), + Math.min(from.getBlockY(), to.getBlockY()), + dx == dz + ); + } + + private boolean crossesDiagonal(DiagonalStep diagonal, Set otherDiagonals) { + for (int yOffset : VERTICAL_OFFSETS) { + DiagonalStep crossing = new DiagonalStep( + diagonal.x(), + diagonal.z(), + diagonal.y() + yOffset, + !diagonal.positiveSlope() + ); + + if (otherDiagonals.contains(crossing)) + return true; + } + + return false; + } + + private boolean crossesPreviousLane(PositionKey center, Set previousLanePositions) { + for (int yOffset : VERTICAL_OFFSETS) { + PositionKey sameColumnPosition = PositionKey.of(center.x(), center.y() + yOffset, center.z()); + + if (previousLanePositions.contains(sameColumnPosition)) + return true; + } + + return false; + } + + private record DiagonalStep(int x, int z, int y, boolean positiveSlope) { + } + + private record LaneGeometry(Set centers, Set diagonals) { + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPreparationProgress.java similarity index 57% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPreparationProgress.java index f653b082..ef33ad8a 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPreparationProgress.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.alpsbte.alpslib.utils.ChatHelper; import net.buildtheearth.buildteamtools.BuildTeamTools; @@ -6,18 +6,21 @@ import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + final class RailPreparationProgress implements Runnable { private final Player player; private final long maxPercentage; private final long updateIntervalTicks; - private volatile BukkitTask task; - private volatile long stageStartPercentage; - private volatile long stageEndPercentage; - private volatile long stageStartedAtMillis = System.currentTimeMillis(); - private volatile long stageEstimatedDurationMillis = 1L; - private volatile long queuedPercentage = -1L; - private volatile long lastSentPercentage = -1L; + private final AtomicReference task = new AtomicReference<>(); + private final AtomicLong stageStartPercentage = new AtomicLong(); + private final AtomicLong stageEndPercentage = new AtomicLong(); + private final AtomicLong stageStartedAtMillis = new AtomicLong(System.currentTimeMillis()); + private final AtomicLong stageEstimatedDurationMillis = new AtomicLong(1L); + private final AtomicLong queuedPercentage = new AtomicLong(-1L); + private final AtomicLong lastSentPercentage = new AtomicLong(-1L); RailPreparationProgress(Player player, long maxPercentage, long updateIntervalTicks) { this.player = player; @@ -29,36 +32,39 @@ void start() { if (!canContinue()) return; - if (task != null) + if (task.get() != null) return; - task = Bukkit.getScheduler().runTaskTimerAsynchronously( + BukkitTask newTask = Bukkit.getScheduler().runTaskTimerAsynchronously( BuildTeamTools.getInstance(), this, 0L, updateIntervalTicks ); + + if (!task.compareAndSet(null, newTask)) + newTask.cancel(); } void stop() { - BukkitTask currentTask = task; + BukkitTask currentTask = task.getAndSet(null); if (currentTask == null) return; currentTask.cancel(); - task = null; } void startStage(long startPercentage, long endPercentage, long estimatedDurationMillis) { if (!canContinue()) return; - stageStartPercentage = clamp(startPercentage); - stageEndPercentage = clamp(endPercentage); - stageStartedAtMillis = System.currentTimeMillis(); - stageEstimatedDurationMillis = Math.max(1L, estimatedDurationMillis); - update(stageStartPercentage); + long clampedStartPercentage = clamp(startPercentage); + stageStartPercentage.set(clampedStartPercentage); + stageEndPercentage.set(clamp(endPercentage)); + stageStartedAtMillis.set(System.currentTimeMillis()); + stageEstimatedDurationMillis.set(Math.max(1L, estimatedDurationMillis)); + update(clampedStartPercentage); } void completeStage(long percentage) { @@ -71,14 +77,14 @@ void update(long percentage) { long clampedPercentage = clamp(percentage); - if (clampedPercentage <= queuedPercentage) + if (clampedPercentage <= queuedPercentage.get()) return; - queuedPercentage = clampedPercentage; - if (clampedPercentage <= lastSentPercentage) + queuedPercentage.set(clampedPercentage); + if (clampedPercentage <= lastSentPercentage.get()) return; - lastSentPercentage = clampedPercentage; + lastSentPercentage.set(clampedPercentage); player.sendActionBar(ChatHelper.getStandardComponent(false, "Generator Progress: %s", clampedPercentage + "%")); } @@ -86,7 +92,7 @@ long scale(int completed, int total, long startPercentage, long endPercentage) { if (total <= 0) return endPercentage; - double progress = Math.max(0D, Math.min(1D, (double) completed / (double) total)); + double progress = Math.clamp((double) completed / (double) total, 0D, 1D); return startPercentage + Math.round(progress * (endPercentage - startPercentage)); } @@ -97,14 +103,14 @@ public void run() { return; } - long currentStageStart = stageStartPercentage; - long currentStageEnd = stageEndPercentage; + long currentStageStart = stageStartPercentage.get(); + long currentStageEnd = stageEndPercentage.get(); if (currentStageEnd <= currentStageStart) return; - long elapsedMillis = Math.max(0L, System.currentTimeMillis() - stageStartedAtMillis); - double progress = Math.min(0.98D, (double) elapsedMillis / (double) stageEstimatedDurationMillis); + long elapsedMillis = Math.max(0L, System.currentTimeMillis() - stageStartedAtMillis.get()); + double progress = Math.clamp((double) elapsedMillis / (double) stageEstimatedDurationMillis.get(), 0D, 0.98D); long estimatedPercentage = currentStageStart + (long) Math.floor(progress * (currentStageEnd - currentStageStart)); if (estimatedPercentage >= currentStageEnd) @@ -114,7 +120,7 @@ public void run() { } private long clamp(long percentage) { - return Math.max(0L, Math.min(maxPercentage, percentage)); + return Math.clamp(percentage, 0L, maxPercentage); } private boolean canContinue() { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailScripts.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java similarity index 72% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailScripts.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java index 1d055b74..fc19a4b4 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailScripts.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java @@ -1,12 +1,18 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.alpsbte.alpslib.utils.ChatHelper; import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.regions.Region; import com.sk89q.worldedit.world.block.BlockState; import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.Script; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import org.bukkit.Bukkit; import org.bukkit.block.Block; import org.bukkit.entity.Player; @@ -20,7 +26,7 @@ public class RailScripts extends Script { private static final int SELECTION_PADDING = 4; - private static final int SELECTION_VERTICAL_PADDING = 12; + private static final int SELECTION_VERTICAL_PADDING = RailType.MAX_OVERHEAD_POLE_HEIGHT + 2; private static final int PREPARE_SELECTION_EXPANSION = 8; private static final long BLOCK_PLACEMENT_START_PERCENTAGE = 95L; @@ -42,23 +48,27 @@ public class RailScripts extends Script { private static final long RAIL_BLOCK_BUILD_ESTIMATED_MILLIS = 1_800L; private static final long QUEUE_OPERATIONS_ESTIMATED_MILLIS = 300L; - private static final int DEFAULT_RAIL_LANE_COUNT = 1; - private static final int DEFAULT_RAIL_LANE_SPACING = 5; - private Block[][][] blocks; private List controlPoints = new ArrayList<>(); private List centerPath = new ArrayList<>(); private RailTerrainResolver terrainResolver; - private RailType railType = RailType.STANDARD; + private RailType railType = RailType.getDefault(); + private int trackCount = RailType.DEFAULT_TRACK_COUNT; + private List trackSpacings = List.of(RailType.DEFAULT_TRACK_SPACING); private final RailLimits limits; private final RailPreparationProgress preparationProgress; private final Runnable preparationFinishedCallback; private int railReferenceY; + private Region generationRegion; public RailScripts(Player player, GeneratorComponent generatorComponent, Runnable preparationFinishedCallback) { super(player, generatorComponent); this.limits = RailLimits.fromConfig(); - this.preparationProgress = new RailPreparationProgress(player, BLOCK_PLACEMENT_START_PERCENTAGE, PROGRESS_UPDATE_INTERVAL_TICKS); + this.preparationProgress = new RailPreparationProgress( + player, + BLOCK_PLACEMENT_START_PERCENTAGE, + PROGRESS_UPDATE_INTERVAL_TICKS + ); this.preparationFinishedCallback = preparationFinishedCallback; preparationProgress.start(); @@ -90,6 +100,10 @@ public RailScripts(Player player, GeneratorComponent generatorComponent, Runnabl private boolean prepareSession() { if (!canContinue()) return false; + railType = getRailType(); + + if (!resolveTrackLayout()) return false; + controlPoints = getControlPoints(); railReferenceY = getRailReferenceY(controlPoints); preparationProgress.completeStage(CONTROL_POINTS_PROGRESS); @@ -104,7 +118,6 @@ private boolean prepareSession() { if (!hasValidCenterPath()) return false; preparationProgress.startStage(PATH_PROGRESS, SAFETY_CHECK_PROGRESS, SAFETY_CHECK_ESTIMATED_MILLIS); - if (!hasSafeEstimatedBlockCount(centerPath)) return false; int selectionMinY = getSelectionMinY(controlPoints); int selectionMaxY = getSelectionMaxY(controlPoints); @@ -121,6 +134,7 @@ private boolean prepareSession() { selectionMinY, selectionMaxY ); + generationRegion = GeneratorUtils.getWorldEditSelection(getPlayer()); blocks = GeneratorUtils.prepareScriptSession( localSession, @@ -132,6 +146,11 @@ private boolean prepareSession() { false, false ); + if (blocks == null) { + sendRailError("Region not readable. Please report this to the developers of the BuildTeamTool plugin."); + return false; + } + terrainResolver = new RailTerrainResolver(blocks); preparationProgress.completeStage(TERRAIN_PREPARE_PROGRESS); @@ -141,7 +160,6 @@ private boolean prepareSession() { snapMissingControlPointHeightsToTerrain(controlPoints); centerPath = createCenterPath(controlPoints); adjustCenterPathToTerrain(); - railType = getRailType(); preparationProgress.completeStage(TERRAIN_ADJUST_PROGRESS); return true; @@ -154,9 +172,34 @@ private boolean queueRailGeneration() { return false; preparationProgress.startStage(TERRAIN_ADJUST_PROGRESS, RAIL_BLOCK_BUILD_PROGRESS, RAIL_BLOCK_BUILD_ESTIMATED_MILLIS); - Map railBlocks = buildRailBlocks(centerPath); + List> railCenterPaths = new RailLanePathBuilder(controlPoints, terrainResolver, trackCount, trackSpacings) + .createRailCenterPaths(centerPath); + + if (railCenterPaths.size() < trackCount) { + sendRailError( + "Rail Generator could not create %s parallel tracks with spacing %s along this path. " + + "Reduce the track count or spacings, or use a less sharp curve.", + trackCount, + trackSpacings + ); + return false; + } + + if (new RailPathOverlapValidator().hasOverlap(railCenterPaths)) { + sendRailError( + "The parallel tracks overlap in this curve. Reduce the track count or spacing, " + + "or make the selected path less sharp." + ); + return false; + } + + Map railBlocks = buildRailBlocks(railCenterPaths); + new RailOverheadBuilder(terrainResolver, railType).addTo(railBlocks, railCenterPaths); preparationProgress.completeStage(RAIL_BLOCK_BUILD_PROGRESS); + if (!isInsideGenerationRegion(railBlocks)) + return false; + if (railBlocks.size() > limits.maxBlockPlacements()) { sendRailError( "Rail Generator would place %s blocks. The limit is %s. Split the rail into smaller selections.", @@ -183,10 +226,24 @@ private boolean queueRailGeneration() { return true; } + private boolean isInsideGenerationRegion(Map railBlocks) { + if (generationRegion != null && railBlocks.keySet().stream().allMatch(position -> generationRegion.contains( + BlockVector3.at(position.x(), position.y(), position.z()) + ))) + return true; + + sendRailError( + "The generated railway does not fit inside its safe generation area. " + + "Reduce the track count or spacing, disable overhead poles, or use a less sharp curve." + ); + return false; + } + private void queueRailBlockPlacements(Map railBlocks) { List positions = new ArrayList<>(limits.blockPlacementBatchSize()); List blockStates = new ArrayList<>(limits.blockPlacementBatchSize()); + for (Map.Entry entry : railBlocks.entrySet()) { positions.add(entry.getKey().toVector()); blockStates.add(entry.getValue()); @@ -255,16 +312,6 @@ private boolean hasValidCenterPath() { return true; } - private boolean hasSafeEstimatedBlockCount(List path) { - long estimatedBlocks = (long) path.size() * getRailLaneCount() * 5L; - - if (estimatedBlocks <= limits.maxBlockPlacements()) - return true; - - sendRailError("Rail Generator would likely place too many blocks. Split the rail into smaller selections."); - return false; - } - private boolean hasSafePreparedSelection(List selectionPoints, int minY, int maxY) { if (selectionPoints.size() < 2) { sendRailError("Rail Generator could not create a safe preparation selection."); @@ -358,33 +405,74 @@ private List createRailSelectionPoints(List points) { } private int getSelectionPadding() { - int sideLaneCount = (getRailLaneCount() - 1) / 2; - return SELECTION_PADDING + (getRailLaneSpacing() * sideLaneCount) + 2; + int totalTrackSpan = trackSpacings.stream().mapToInt(Integer::intValue).sum(); + int maxLaneOffset = (int) Math.ceil(totalTrackSpan / 2.0D); + int overheadPoleOffset = railType.hasOverheadPoles() ? railType.getOverheadPoleOffset() : 0; + return SELECTION_PADDING + maxLaneOffset + overheadPoleOffset + 2; } private List createCenterPath(List points) { return GeneratorUtils.removeOrthogonalCorners(GeneratorUtils.createShortestBlockPath(points)); } - private Map buildRailBlocks(List path) { + private Map buildRailBlocks(List> railCenterPaths) { return new RailBlockBuilder( - controlPoints, terrainResolver, railType, preparationProgress, - getRailLaneCount(), - getRailLaneSpacing(), TERRAIN_ADJUST_PROGRESS, RAIL_BLOCK_BUILD_PROGRESS - ).build(path); + ).build(railCenterPaths); } - private int getRailLaneCount() { - return DEFAULT_RAIL_LANE_COUNT; + private boolean resolveTrackLayout() { + trackCount = railType.getTrackCount(); + int trackSpacing = railType.getTrackSpacing(); + trackSpacings = railType.getTrackSpacings(); + + Integer trackCountFlag = getIntegerSetting(RailFlag.TRACK_COUNT); + Integer trackSpacingFlag = getIntegerSetting(RailFlag.TRACK_SPACING); + + if (trackCountFlag != null) + trackCount = trackCountFlag; + + if (trackSpacingFlag != null) { + trackSpacing = trackSpacingFlag; + trackSpacings = Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); + } else if (trackCount != railType.getTrackCount()) { + trackSpacings = Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); + } + + if (trackCount < RailType.MIN_TRACK_COUNT || trackCount > RailType.MAX_TRACK_COUNT) { + sendRailError("Track count must be between %s and %s.", RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT); + return false; + } + + if (trackSpacing < RailType.MIN_TRACK_SPACING || trackSpacing > RailType.MAX_TRACK_SPACING) { + sendRailError("Track spacing must be between %s and %s.", RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING); + return false; + } + + if (trackSpacings.size() != Math.max(0, trackCount - 1) + || trackSpacings.stream().anyMatch(spacing -> spacing < RailType.MIN_TRACK_SPACING + || spacing > RailType.MAX_TRACK_SPACING)) { + sendRailError("Every track spacing must be between %s and %s.", + RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING); + return false; + } + + return trackCount == RailType.MIN_TRACK_COUNT + || Permissions.checkPermission(getPlayer(), Permissions.RAIL_MULTIPLE_TRACKS); } - private int getRailLaneSpacing() { - return DEFAULT_RAIL_LANE_SPACING; + private Integer getIntegerSetting(RailFlag flag) { + Settings settings = getGeneratorComponent().getPlayerSettings().get(getPlayer().getUniqueId()); + + if (!(settings instanceof RailSettings railSettings)) + return null; + + Object value = railSettings.getValues().get(flag); + return value instanceof Integer intValue ? intValue : null; } private void snapMissingControlPointHeightsToTerrain(List points) { @@ -400,7 +488,12 @@ private void adjustCenterPathToTerrain() { for (int index = 0; index < centerPath.size(); index++) { Vector point = centerPath.get(index); point.setY(terrainResolver.getNearestRailSurfaceY(point.getBlockX(), point.getBlockZ(), point.getBlockY())); - preparationProgress.update(preparationProgress.scale(index + 1, centerPath.size(), TERRAIN_PREPARE_PROGRESS, TERRAIN_ADJUST_PROGRESS)); + preparationProgress.update(preparationProgress.scale( + index + 1, + centerPath.size(), + TERRAIN_PREPARE_PROGRESS, + TERRAIN_ADJUST_PROGRESS + )); } } @@ -429,10 +522,10 @@ private RailType getRailType() { Settings settings = getGeneratorComponent().getPlayerSettings().get(getPlayer().getUniqueId()); if (!(settings instanceof RailSettings railSettings)) - return RailType.STANDARD; + return RailType.getDefault(); Object value = railSettings.getValues().get(RailFlag.RAIL_TYPE); - return value instanceof RailType selectedRailType ? selectedRailType : RailType.STANDARD; + return value instanceof RailType selectedRailType ? selectedRailType : RailType.getDefault(); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSideBlock.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSideBlock.java similarity index 97% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSideBlock.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSideBlock.java index 67b91f2c..b6c6cc90 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSideBlock.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSideBlock.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.sk89q.worldedit.util.Direction; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSidePlacement.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSidePlacement.java similarity index 88% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSidePlacement.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSidePlacement.java index 20c40538..31fb5486 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSidePlacement.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSidePlacement.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.sk89q.worldedit.util.Direction; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailStep.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailStep.java similarity index 51% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailStep.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailStep.java index 0de43935..e6cc6472 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailStep.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailStep.java @@ -1,4 +1,8 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; record RailStep(int dx, int dz) { + + RailStep opposite() { + return new RailStep(-dx, -dz); + } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTerrainResolver.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailTerrainResolver.java similarity index 99% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTerrainResolver.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailTerrainResolver.java index 2ae5ff29..4b55ff9d 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTerrainResolver.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailTerrainResolver.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import net.buildtheearth.buildteamtools.utils.MenuItems; import org.bukkit.Material; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java new file mode 100644 index 00000000..3acc0033 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java @@ -0,0 +1,167 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.buildtheearth.buildteamtools.utils.menus.BlockListMenu; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Selects independent material palettes for every building role in a draft. + * Cursor items are sampled without moving or consuming them. + */ +public class RailBlockPickerMenu extends BlockListMenu { + + private static final int CUSTOM_BLOCK_SLOT = 28; + private static final int SELECTION_SLOT = 29; + + private final RailTypeDraft draft; + private final RailBlockRole role; + private final List choices; + + RailBlockPickerMenu(Player player, RailTypeDraft draft, RailBlockRole role, boolean autoLoad) { + this(player, draft, role, autoLoad, createChoices(player, draft, role)); + } + + private RailBlockPickerMenu(Player player, RailTypeDraft draft, RailBlockRole role, + boolean autoLoad, List choices) { + super(player, role.getMenuTitle(), choices, createParentMenu(player, draft, role), false); + + this.draft = draft; + this.role = role; + this.choices = choices; + selectedMaterials = new ArrayList<>(role.getSelected(draft).stream().map(XMaterial::name).toList()); + if (autoLoad) + reloadMenuAsync(); + } + + private static List createChoices(Player player, RailTypeDraft draft, RailBlockRole role) { + Map choices = new LinkedHashMap<>(); + if (role == RailBlockRole.RAIL_BLOCK) + for (Material material : List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL)) + choices.put(material, new ItemStack(material)); + for (XMaterial material : role.getSelected(draft)) { + if (material.get() != null) + choices.put(material.get(), new ItemStack(material.get())); + } + for (ItemStack item : player.getInventory().getContents()) { + if (isValidItem(item, role)) + choices.putIfAbsent(item.getType(), new ItemStack(item.getType())); + } + for (ItemStack item : role.createChoices()) + choices.putIfAbsent(item.getType(), item); + return new ArrayList<>(choices.values()); + } + + private static boolean isValidItem(ItemStack item, RailBlockRole role) { + return item != null && !item.getType().isAir() + && (role == RailBlockRole.ICON || item.getType().isBlock()); + } + + @Override + protected void setPreviewItems() { + super.setPreviewItems(); + getMenu().getSlot(CUSTOM_BLOCK_SLOT).setItem(Item.create(Material.HOPPER, "§eUse Your Own Block", + List.of("§7Pick up an item from your inventory,", "§7then click here to select its material.", + "§7Your item is not consumed."))); + getMenu().getSlot(SELECTION_SLOT).setItem(Item.create(Material.PAPER, "§eSelected Materials", + List.of("§f" + String.join(", ", selectedMaterials), + role != RailBlockRole.ICON + ? "§7Click blocks to add/remove them from the palette." + : "§7Choose one material.", "§7Click Next to apply; Back discards changes."))); + getMenu().update(getMenuPlayer()); + } + + @Override + protected void setPaginatedItemClickEventsAsync(List source) { + List itemStacks = source.stream().map(l -> (ItemStack) l).toList(); + int slot = 0; + + for (ItemStack ignored : itemStacks) { + final int _slot = slot; + getMenu().getSlot(_slot).setClickHandler((clickPlayer, clickInformation) -> { + String type = Item.getUppercaseMaterialString(getMenu().getSlot(_slot).getItem(getMenuPlayer())); + + if (selectedMaterials.contains(type)) { + selectedMaterials.removeIf(type::equals); + } else { + if (role == RailBlockRole.ICON) + selectedMaterials.clear(); + selectedMaterials.add(type); + } + + reloadMenuAsync(); + }); + slot++; + } + } + + @Override + protected void setItemClickEventsAsync() { + super.setItemClickEventsAsync(); + + getMenu().getSlot(CUSTOM_BLOCK_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + clickInformation.setResult(Event.Result.DENY); + ItemStack item = clickPlayer.getItemOnCursor(); + if (!isValidItem(item, role)) { + clickPlayer.sendMessage("§cPick up a " + (role == RailBlockRole.ICON ? "non-empty item" : "block") + + " from your inventory first."); + return; + } + String material = XMaterial.matchXMaterial(item.getType()).name(); + if (role == RailBlockRole.ICON) + selectedMaterials.clear(); + if (!selectedMaterials.contains(material)) + selectedMaterials.add(material); + if (choices.stream().noneMatch(choice -> choice.getType() == item.getType())) + choices.add(new ItemStack(item.getType())); + // Finish the cancelled cursor transaction before redrawing the inventory. + Bukkit.getScheduler().runTask(BuildTeamTools.getInstance(), () -> { + if (getMenu().isOpen(clickPlayer)) + reloadMenuAsync(); + }); + }); + + getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (!canProceed()) + return; + List materials = selectedMaterials.stream().map(Item::convertStringToXMaterial).toList(); + + if (materials.stream().anyMatch(Objects::isNull)) + return; + + role.apply(draft, materials); + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + if (role.isOverheadSetting()) + new RailOverheadWireMenu(clickPlayer, draft, true); + else + new RailTypeEditorMenu(clickPlayer, draft, true); + }); + } + + private static AbstractMenu createParentMenu(Player player, RailTypeDraft draft, RailBlockRole role) { + if (role.isOverheadSetting()) + return new RailOverheadWireMenu(player, draft, false); + + return new RailTypeEditorMenu(player, draft, false); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java new file mode 100644 index 00000000..21bfef50 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java @@ -0,0 +1,161 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.cryptomorin.xseries.XMaterial; +import lombok.Getter; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.List; +import java.util.ArrayList; + +/** + * The block slots of a rail type that can be configured in the editor menu. + * Each role provides its own predefined list of valid blocks to choose from. + */ +enum RailBlockRole { + + RAIL_BLOCK("Choose a Rail Block") { + @Override + List createChoices() { + List choices = new ArrayList<>(); + choices.add(new ItemStack(Material.ANVIL)); + choices.add(new ItemStack(Material.CHIPPED_ANVIL)); + choices.add(new ItemStack(Material.DAMAGED_ANVIL)); + choices.addAll(MenuItems.getBlocksByColor()); + return choices; + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setRailBlock(material); + } + }, + + BLOCK_BELOW("Choose a Block Below the Rails") { + @Override + List createChoices() { + return MenuItems.getSolidBlocks(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setBlocksBelow(List.of(material)); + } + }, + + SLEEPER_BLOCK("Choose a Sleeper Block") { + @Override + List createChoices() { + return MenuItems.getSolidBlocks(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setSleeperBlock(material); + } + }, + + ICON("Choose a Rail Type Icon") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setIcon(material); + } + }, + + OVERHEAD_POLE_BLOCK("Choose an Overhead Pole Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setOverheadPoleBlock(material); + } + + @Override + boolean isOverheadSetting() { + return true; + } + }, + + OVERHEAD_SUPPORT_BLOCK("Choose a Top Support Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setOverheadSupportBlock(material); + } + + @Override + boolean isOverheadSetting() { + return true; + } + }, + + OVERHEAD_WIRE_BLOCK("Choose an Overhead Wire Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setOverheadWireBlock(material); + } + + @Override + boolean isOverheadSetting() { + return true; + } + }; + + @Getter + private final String menuTitle; + + RailBlockRole(String menuTitle) { + this.menuTitle = menuTitle; + } + + abstract List createChoices(); + + abstract void apply(RailTypeDraft draft, XMaterial material); + + List getSelected(RailTypeDraft draft) { + return switch (this) { + case BLOCK_BELOW -> draft.getBlocksBelow(); + case RAIL_BLOCK -> draft.getRailBlocks(); + case SLEEPER_BLOCK -> draft.getSleeperBlocks(); + case ICON -> List.of(draft.getIcon()); + case OVERHEAD_POLE_BLOCK -> draft.getOverheadPoleBlocks(); + case OVERHEAD_SUPPORT_BLOCK -> draft.getOverheadSupportBlocks(); + case OVERHEAD_WIRE_BLOCK -> draft.getOverheadWireBlocks(); + }; + } + + void apply(RailTypeDraft draft, List materials) { + switch (this) { + case BLOCK_BELOW -> draft.setBlocksBelow(List.copyOf(materials)); + case RAIL_BLOCK -> draft.setRailBlocks(List.copyOf(materials)); + case SLEEPER_BLOCK -> draft.setSleeperBlocks(List.copyOf(materials)); + case OVERHEAD_POLE_BLOCK -> draft.setOverheadPoleBlocks(List.copyOf(materials)); + case OVERHEAD_SUPPORT_BLOCK -> draft.setOverheadSupportBlocks(List.copyOf(materials)); + case OVERHEAD_WIRE_BLOCK -> draft.setOverheadWireBlocks(List.copyOf(materials)); + case ICON -> apply(draft, materials.getFirst()); + } + } + + boolean isOverheadSetting() { + return false; + } + +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java new file mode 100644 index 00000000..1853c088 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java @@ -0,0 +1,16 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.ChatHelper; +import lombok.experimental.UtilityClass; +import net.kyori.adventure.text.format.NamedTextColor; + +@UtilityClass +final class RailMenuText { + + static final String ENABLED = "Enabled"; + static final String DISABLED = "Disabled"; + + static String color(NamedTextColor color, String text) { + return ChatHelper.getColorizedString(color, text, false); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java new file mode 100644 index 00000000..ff36bcf4 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java @@ -0,0 +1,318 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.ChatHelper; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import net.buildtheearth.buildteamtools.utils.heads.HeadColor; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.wesjd.anvilgui.AnvilGUI; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.ipvp.canvas.mask.BinaryMask; +import org.ipvp.canvas.mask.Mask; + +import java.util.List; +import java.util.Objects; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; + +final class RailOverheadWireMenu extends AbstractMenu { + + private static final String MENU_TITLE = "Overhead Wire Settings"; + private static final String FILLED_MASK_ROW = "111111111"; + private static final String BLOCKS_UNIT = "Blocks"; + + private static final int POLES_TOGGLE_SLOT = 10; + private static final int POLE_BLOCK_SLOT = 16; + private static final int SUPPORT_BLOCK_SLOT = 22; + private static final int WIRES_TOGGLE_SLOT = 19; + private static final int WIRE_BLOCK_SLOT = 25; + private static final int POLE_SPACING_SLOT = 28; + private static final int POLE_OFFSET_SLOT = 31; + private static final int POLE_HEIGHT_SLOT = 34; + private static final int BACK_ITEM_SLOT = 36; + private static final int DONE_ITEM_SLOT = 44; + + private final RailTypeDraft draft; + + RailOverheadWireMenu(Player player, RailTypeDraft draft, boolean autoLoad) { + super(5, MENU_TITLE, player, false); + this.draft = draft; + + if (autoLoad) + reloadMenuAsync(); + } + + @Override + protected void setPreviewItems() { + if (getMask() != null) + getMask().apply(getMenu()); + + getMenu().getSlot(POLES_TOGGLE_SLOT).setItem(createToggleItem( + "Overhead Poles", + draft.isOverheadPolesEnabled(), + "Generate poles alongside the tracks." + )); + getMenu().getSlot(WIRES_TOGGLE_SLOT).setItem(createToggleItem( + "Overhead Wires", + draft.isOverheadWiresEnabled(), + "Generate wires above the tracks." + )); + getMenu().getSlot(POLE_BLOCK_SLOT).setItem(createBlockItem( + "Pole Block", + draft.getOverheadPoleBlocks() + )); + getMenu().getSlot(SUPPORT_BLOCK_SLOT).setItem(createBlockItem( + "Top Support Block", + draft.getOverheadSupportBlocks() + )); + getMenu().getSlot(WIRE_BLOCK_SLOT).setItem(createBlockItem( + "Wire Block", + draft.getOverheadWireBlocks() + )); + + getMenu().getSlot(POLE_SPACING_SLOT).setItem(createSpacingItem()); + createCounter( + HeadColor.LIGHT_GRAY, + POLE_OFFSET_SLOT, + "Pole Offset", + draft.getOverheadPoleOffset(), + RailType.MIN_OVERHEAD_POLE_OFFSET, + RailType.MAX_OVERHEAD_POLE_OFFSET, + BLOCKS_UNIT + ); + createCounter( + HeadColor.WHITE, + POLE_HEIGHT_SLOT, + "Pole Height", + draft.getOverheadPoleHeight(), + RailType.MIN_OVERHEAD_POLE_HEIGHT, + RailType.MAX_OVERHEAD_POLE_HEIGHT, + BLOCKS_UNIT + ); + + setBackItem(BACK_ITEM_SLOT, new RailTypeEditorMenu(getMenuPlayer(), draft, false)); + getMenu().getSlot(DONE_ITEM_SLOT).setItem(HeadFactory.head( + HeadTexture.CHECKMARK, + green("Done") + )); + getMenu().open(getMenuPlayer()); + } + + @Override + protected void setMenuItemsAsync() { + // All items use local draft state. + } + + @Override + protected void setItemClickEventsAsync() { + getMenu().getSlot(POLES_TOGGLE_SLOT).setClickHandler((player, click) -> { + draft.setOverheadPolesEnabled(!draft.isOverheadPolesEnabled()); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + getMenu().getSlot(WIRES_TOGGLE_SLOT).setClickHandler((player, click) -> { + draft.setOverheadWiresEnabled(!draft.isOverheadWiresEnabled()); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + + setBlockPickerClickEvent(POLE_BLOCK_SLOT, RailBlockRole.OVERHEAD_POLE_BLOCK); + setBlockPickerClickEvent(SUPPORT_BLOCK_SLOT, RailBlockRole.OVERHEAD_SUPPORT_BLOCK); + setBlockPickerClickEvent(WIRE_BLOCK_SLOT, RailBlockRole.OVERHEAD_WIRE_BLOCK); + getMenu().getSlot(POLE_SPACING_SLOT).setClickHandler((player, click) -> openSpacingEditor(player)); + setCounterClickEvents( + POLE_OFFSET_SLOT, + RailType.MIN_OVERHEAD_POLE_OFFSET, + RailType.MAX_OVERHEAD_POLE_OFFSET, + draft::getOverheadPoleOffset, + draft::setOverheadPoleOffset + ); + setCounterClickEvents( + POLE_HEIGHT_SLOT, + RailType.MIN_OVERHEAD_POLE_HEIGHT, + RailType.MAX_OVERHEAD_POLE_HEIGHT, + draft::getOverheadPoleHeight, + draft::setOverheadPoleHeight + ); + + getMenu().getSlot(DONE_ITEM_SLOT).setClickHandler((player, click) -> { + player.closeInventory(); + playSound(player, Sound.UI_BUTTON_CLICK); + new RailTypeEditorMenu(player, draft, true); + }); + } + + @Override + protected Mask getMask() { + return BinaryMask.builder(getMenu()) + .item(MenuItems.ITEM_BACKGROUND) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .build(); + } + + private void setBlockPickerClickEvent(int slot, RailBlockRole role) { + getMenu().getSlot(slot).setClickHandler((player, click) -> { + player.closeInventory(); + playSound(player, Sound.UI_BUTTON_CLICK); + new RailBlockPickerMenu(player, draft, role, true); + }); + } + + private void setCounterClickEvents( + int slot, + int minimum, + int maximum, + IntSupplier getter, + IntConsumer setter + ) { + getMenu().getSlot(slot - 1).setClickHandler((player, click) -> + changeCounter(player, minimum, getter.getAsInt() - 1, getter, setter)); + getMenu().getSlot(slot + 1).setClickHandler((player, click) -> + changeCounter(player, maximum, getter.getAsInt() + 1, getter, setter)); + } + + private void changeCounter( + Player player, + int boundary, + int newValue, + IntSupplier getter, + IntConsumer setter + ) { + if (getter.getAsInt() == boundary) { + playSound(player, Sound.ENTITY_ITEM_BREAK); + return; + } + + setter.accept(newValue); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + } + + private ItemStack createToggleItem(String name, boolean enabled, String description) { + return Item.create( + Objects.requireNonNull(XMaterial.LEVER.get()), + yellow(name + ": ") + (enabled + ? green(RailMenuText.ENABLED) + : red(RailMenuText.DISABLED)), + List.of(gray(description), gray("Click to toggle.")) + ); + } + + private ItemStack createBlockItem(String name, List materials) { + Material bukkitMaterial = materials.getFirst().get(); + + if (bukkitMaterial == null) + bukkitMaterial = Objects.requireNonNull(XMaterial.BARRIER.get()); + + return Item.create( + bukkitMaterial, + yellow(name + ": ") + RailTypeMenu.formatMaterials(materials), + List.of(gray("Click to choose one or more materials.")) + ); + } + + private ItemStack createSpacingItem() { + return Item.create( + Objects.requireNonNull(XMaterial.NAME_TAG.get()), + yellow("Pole Spacing: ") + white(draft.getOverheadPoleSpacing() + " " + BLOCKS_UNIT), + List.of( + gray("Click to enter a custom spacing."), + gray("Valid range: ") + white(RailType.MIN_OVERHEAD_POLE_SPACING + + "-" + RailType.MAX_OVERHEAD_POLE_SPACING + " " + BLOCKS_UNIT) + ) + ); + } + + private void openSpacingEditor(Player clickPlayer) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new AnvilGUI.Builder() + .onClick((slot, stateSnapshot) -> { + if (slot != AnvilGUI.Slot.OUTPUT) + return List.of(); + + String input = stateSnapshot.getText().trim(); + Integer spacing = parseSpacing(input); + + if (spacing == null) { + stateSnapshot.getPlayer().sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Pole spacing must be a number between %s and %s.", + RailType.MIN_OVERHEAD_POLE_SPACING, + RailType.MAX_OVERHEAD_POLE_SPACING + ))); + return List.of(AnvilGUI.ResponseAction.replaceInputText(String.valueOf(draft.getOverheadPoleSpacing()))); + } + + draft.setOverheadPoleSpacing(spacing); + playSound(stateSnapshot.getPlayer(), Sound.UI_BUTTON_CLICK); + stateSnapshot.getPlayer().sendMessage(ChatHelper.getStandardComponent( + true, + "Pole spacing set to %s blocks.", + spacing + )); + return List.of( + AnvilGUI.ResponseAction.close(), + AnvilGUI.ResponseAction.run(() -> new RailOverheadWireMenu(clickPlayer, draft, true)) + ); + }) + .text(String.valueOf(draft.getOverheadPoleSpacing())) + .itemLeft(Item.create(Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Change Pole Spacing"))) + .title(darkGray("Change pole spacing")) + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private Integer parseSpacing(String input) { + try { + int spacing = Integer.parseInt(input); + + if (spacing < RailType.MIN_OVERHEAD_POLE_SPACING || spacing > RailType.MAX_OVERHEAD_POLE_SPACING) + return null; + + return spacing; + } catch (NumberFormatException exception) { + return null; + } + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + + private static String green(String text) { + return RailMenuText.color(NamedTextColor.GREEN, text); + } + + private static String red(String text) { + return RailMenuText.color(NamedTextColor.RED, text); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java new file mode 100644 index 00000000..d8228439 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java @@ -0,0 +1,105 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import net.buildtheearth.buildteamtools.utils.heads.HeadColor; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.ipvp.canvas.mask.BinaryMask; +import org.ipvp.canvas.mask.Mask; + +/** Configures the distance for each gap between up to eight parallel tracks. */ +final class RailTrackSpacingMenu extends AbstractMenu { + + private static final int[] GAP_SLOTS = {2, 6, 11, 15, 20, 24, 29}; + private static final String FILLED_MASK_ROW = "111111111"; + private static final int BACK_ITEM_SLOT = 36; + private static final int DONE_ITEM_SLOT = 44; + + private final RailTypeDraft draft; + + RailTrackSpacingMenu(Player player, RailTypeDraft draft, boolean autoLoad) { + super(5, "Track Spacing Settings", player, false); + this.draft = draft; + + if (autoLoad) + reloadMenuAsync(); + } + + @Override + protected void setPreviewItems() { + if (getMask() != null) + getMask().apply(getMenu()); + + for (int gapIndex = 0; gapIndex < draft.getTrackSpacings().size(); gapIndex++) { + createCounter( + gapIndex % 2 == 0 ? HeadColor.WHITE : HeadColor.LIGHT_GRAY, + GAP_SLOTS[gapIndex], + "Tracks " + (gapIndex + 1) + " & " + (gapIndex + 2), + draft.getTrackSpacings().get(gapIndex), + RailType.MIN_TRACK_SPACING, + RailType.MAX_TRACK_SPACING, + "Blocks" + ); + } + + setBackItem(BACK_ITEM_SLOT, new RailTypeEditorMenu(getMenuPlayer(), draft, false)); + getMenu().getSlot(DONE_ITEM_SLOT).setItem(HeadFactory.head(HeadTexture.CHECKMARK, "§aDone")); + getMenu().open(getMenuPlayer()); + } + + @Override + protected void setMenuItemsAsync() { + // All values live in the local draft. + } + + @Override + protected void setItemClickEventsAsync() { + for (int gapIndex = 0; gapIndex < draft.getTrackSpacings().size(); gapIndex++) { + int selectedGap = gapIndex; + int slot = GAP_SLOTS[gapIndex]; + + getMenu().getSlot(slot - 1).setClickHandler((player, click) -> changeSpacing(player, selectedGap, -1)); + getMenu().getSlot(slot + 1).setClickHandler((player, click) -> changeSpacing(player, selectedGap, 1)); + } + + getMenu().getSlot(DONE_ITEM_SLOT).setClickHandler((player, click) -> { + player.closeInventory(); + playSound(player, Sound.UI_BUTTON_CLICK); + new RailTypeEditorMenu(player, draft, true); + }); + } + + private void changeSpacing(Player player, int gapIndex, int change) { + int current = draft.getTrackSpacings().get(gapIndex); + int updated = current + change; + + if (updated < RailType.MIN_TRACK_SPACING || updated > RailType.MAX_TRACK_SPACING) { + playSound(player, Sound.ENTITY_ITEM_BREAK); + return; + } + + draft.setTrackSpacing(gapIndex, updated); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + } + + @Override + protected Mask getMask() { + return BinaryMask.builder(getMenu()) + .item(MenuItems.ITEM_BACKGROUND) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .build(); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java new file mode 100644 index 00000000..f5fd8d0e --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java @@ -0,0 +1,143 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.cryptomorin.xseries.XMaterial; +import lombok.Getter; +import lombok.Setter; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; + +import java.util.List; + +/** + * Mutable state of a rail type that is being created in the editor menu. + */ +@Getter +@Setter +class RailTypeDraft { + + private String identifier; + private String displayName; + private XMaterial icon = XMaterial.RAIL; + private List railBlocks = List.of(XMaterial.ANVIL); + private List blocksBelow = List.of(XMaterial.GRAVEL); + private List sleeperBlocks = List.of(XMaterial.SPRUCE_PLANKS); + private int sleeperSpacing = RailType.MIN_SLEEPER_SPACING; + private int trackCount = RailType.DEFAULT_TRACK_COUNT; + private int trackSpacing = RailType.DEFAULT_TRACK_SPACING; + private List trackSpacings = new java.util.ArrayList<>(List.of(RailType.DEFAULT_TRACK_SPACING)); + private boolean overheadPolesEnabled = true; + private List overheadPoleBlocks = List.of(XMaterial.LIGHT_GRAY_CONCRETE); + private List overheadSupportBlocks = List.of(XMaterial.LIGHT_GRAY_CONCRETE); + private int overheadPoleSpacing = RailType.DEFAULT_OVERHEAD_POLE_SPACING; + private int overheadPoleOffset = RailType.DEFAULT_OVERHEAD_POLE_OFFSET; + private int overheadPoleHeight = RailType.DEFAULT_OVERHEAD_POLE_HEIGHT; + private boolean overheadWiresEnabled = true; + private List overheadWireBlocks = List.of(XMaterial.IRON_BARS); + private boolean trackSwitchesEnabled; + + static RailTypeDraft from(RailType railType, boolean keepIdentity) { + RailTypeDraft draft = new RailTypeDraft(); + List blocksBelow = railType.getBlocksBelow(); + + if (keepIdentity) { + draft.identifier = railType.getIdentifier(); + draft.displayName = railType.getDisplayName(); + } + + draft.icon = railType.getIcon(); + draft.railBlocks = railType.getRailBlocks().isEmpty() ? draft.railBlocks : List.copyOf(railType.getRailBlocks()); + draft.blocksBelow = blocksBelow.isEmpty() ? List.of(XMaterial.GRAVEL) : List.copyOf(blocksBelow); + draft.sleeperBlocks = railType.getSleeperBlocks().isEmpty() ? draft.sleeperBlocks : List.copyOf(railType.getSleeperBlocks()); + draft.sleeperSpacing = railType.getSleeperSpacing(); + draft.trackCount = railType.getTrackCount(); + draft.trackSpacing = railType.getTrackSpacing(); + draft.trackSpacings = new java.util.ArrayList<>(railType.getTrackSpacings()); + draft.overheadPolesEnabled = railType.isOverheadPolesEnabled(); + draft.overheadPoleBlocks = railType.getOverheadPoleBlocks().isEmpty() ? draft.overheadPoleBlocks : List.copyOf(railType.getOverheadPoleBlocks()); + draft.overheadSupportBlocks = railType.getOverheadSupportBlocks().isEmpty() ? draft.overheadSupportBlocks : List.copyOf(railType.getOverheadSupportBlocks()); + draft.overheadPoleSpacing = railType.getOverheadPoleSpacing(); + draft.overheadPoleOffset = railType.getOverheadPoleOffset(); + draft.overheadPoleHeight = railType.getOverheadPoleHeight(); + draft.overheadWiresEnabled = railType.isOverheadWiresEnabled(); + draft.overheadWireBlocks = railType.getOverheadWireBlocks().isEmpty() ? draft.overheadWireBlocks : List.copyOf(railType.getOverheadWireBlocks()); + draft.trackSwitchesEnabled = railType.isTrackSwitchesEnabled(); + return draft; + } + + XMaterial getBlockBelow() { + return blocksBelow.getFirst(); + } + + // The picker replaces the mix only after the user explicitly chooses a block. + void setBlockBelow(XMaterial blockBelow) { + blocksBelow = List.of(blockBelow); + } + + XMaterial getRailBlock() { + return railBlocks.getFirst(); + } + + void setRailBlock(XMaterial material) { + railBlocks = List.of(material); + } + + XMaterial getSleeperBlock() { + return sleeperBlocks.getFirst(); + } + + void setSleeperBlock(XMaterial material) { + sleeperBlocks = List.of(material); + } + + XMaterial getOverheadPoleBlock() { + return overheadPoleBlocks.getFirst(); + } + + void setOverheadPoleBlock(XMaterial material) { + overheadPoleBlocks = List.of(material); + } + + XMaterial getOverheadSupportBlock() { + return overheadSupportBlocks.getFirst(); + } + + void setOverheadSupportBlock(XMaterial material) { + overheadSupportBlocks = List.of(material); + } + + XMaterial getOverheadWireBlock() { + return overheadWireBlocks.getFirst(); + } + + void setOverheadWireBlock(XMaterial material) { + overheadWireBlocks = List.of(material); + } + + void setTrackCount(int trackCount) { + this.trackCount = trackCount; + resizeTrackSpacings(); + } + + void setTrackSpacing(int trackSpacing) { + this.trackSpacing = trackSpacing; + this.trackSpacings = new java.util.ArrayList<>(java.util.Collections.nCopies( + Math.max(0, trackCount - 1), + trackSpacing + )); + } + + void setTrackSpacing(int gapIndex, int spacing) { + resizeTrackSpacings(); + trackSpacings.set(gapIndex, spacing); + trackSpacing = trackSpacings.getFirst(); + } + + private void resizeTrackSpacings() { + int requiredSize = Math.max(0, trackCount - 1); + + while (trackSpacings.size() < requiredSize) + trackSpacings.add(trackSpacing); + + while (trackSpacings.size() > requiredSize) + trackSpacings.removeLast(); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java new file mode 100644 index 00000000..20e0ea96 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java @@ -0,0 +1,392 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.ChatHelper; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; +import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import net.buildtheearth.buildteamtools.utils.heads.HeadColor; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.wesjd.anvilgui.AnvilGUI; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.ipvp.canvas.mask.BinaryMask; +import org.ipvp.canvas.mask.Mask; + +import java.util.List; +import java.util.Objects; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; + +/** + * Editor for creating a custom rail type in-game. The configured draft is + * validated and saved persistently through the {@link RailTypeManager}. + */ +public class RailTypeEditorMenu extends AbstractMenu { + + public static final String EDITOR_INV_NAME = "Create a Rail Type"; + private static final String FILLED_MASK_ROW = "111111111"; + + private static final int NAME_SLOT = 14; + + private static final int TRACK_COUNT_SLOT = 2; + private static final int TRACK_SPACING_SLOT = 11; + private static final int SLEEPER_SPACING_SLOT = 20; + + private static final int RAIL_BLOCK_SLOT = 7; + private static final int BLOCK_BELOW_SLOT = 16; + private static final int SLEEPER_BLOCK_SLOT = 25; + private static final int ICON_SLOT = 23; + private static final int OVERHEAD_SETTINGS_SLOT = 32; + private static final int TRACK_SWITCHES_SLOT = 30; + + private static final int BACK_ITEM_SLOT = 36; + private static final int SAVE_ITEM_SLOT = 44; + + private final RailTypeDraft draft; + + RailTypeEditorMenu(Player player, RailTypeDraft draft, boolean autoLoad) { + // The parent constructor renders immediately, so load manually after the draft is set. + super(5, EDITOR_INV_NAME, player, false); + + this.draft = draft; + + if (autoLoad) + reloadMenuAsync(); + } + + @Override + protected void setPreviewItems() { + if (getMask() != null) + getMask().apply(getMenu()); + + getMenu().getSlot(NAME_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NAME_TAG.get()), + yellow("Rail Type Name: ") + white(getDisplayName()), + List.of(gray("Click to change the display name.")) + )); + + createCounter(HeadColor.WHITE, TRACK_COUNT_SLOT, "Track Count", draft.getTrackCount(), + RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT, "Tracks"); + getMenu().getSlot(TRACK_SPACING_SLOT - 1).setItem(HeadFactory.getCounterMinusItem( + HeadColor.LIGHT_GRAY, "Track Spacing", draft.getTrackSpacing(), RailType.MIN_TRACK_SPACING)); + getMenu().getSlot(TRACK_SPACING_SLOT + 1).setItem(HeadFactory.getCounterPlusItem( + HeadColor.LIGHT_GRAY, "Track Spacing", draft.getTrackSpacing(), RailType.MAX_TRACK_SPACING)); + getMenu().getSlot(TRACK_SPACING_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NAME_TAG.get()), + yellow("Track Spacings"), + List.of( + gray("Between tracks: ") + white(draft.getTrackSpacings().toString()), + gray("Use +/- to set all spacings."), + gray("Click to configure each gap separately.") + ) + )); + createCounter(HeadColor.WHITE, SLEEPER_SPACING_SLOT, "Sleeper Spacing", draft.getSleeperSpacing(), + RailType.MIN_SLEEPER_SPACING, RailType.MAX_SLEEPER_SPACING, "Blocks"); + + getMenu().getSlot(RAIL_BLOCK_SLOT).setItem(createBlockItem( + "Rail Block", + draft.getRailBlocks(), + "The block the rails are made of." + )); + getMenu().getSlot(BLOCK_BELOW_SLOT).setItem(createBlockItem( + "Blocks Below the Rails", + draft.getBlocksBelow(), + "The material mix placed between and below the rails." + )); + getMenu().getSlot(SLEEPER_BLOCK_SLOT).setItem(createBlockItem( + "Sleeper Block", + draft.getSleeperBlocks(), + "Sleeper Spacing 0 disables sleepers." + )); + getMenu().getSlot(ICON_SLOT).setItem(createBlockItem( + "Menu Icon", + draft.getIcon(), + "The icon shown in the rail type menu." + )); + getMenu().getSlot(OVERHEAD_SETTINGS_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.IRON_BARS.get()), + yellow("Overhead Settings"), + List.of( + gray("Poles: ") + white(draft.isOverheadPolesEnabled() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED), + gray("Wires: ") + white(draft.isOverheadWiresEnabled() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED), + gray("Click to configure overhead wires.") + ) + )); + getMenu().getSlot(TRACK_SWITCHES_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.LEVER.get()), + yellow("Track Switches: ") + + (draft.isTrackSwitchesEnabled() + ? green(RailMenuText.ENABLED) + : red(RailMenuText.DISABLED)), + List.of( + gray("Prepares this type for future switch generation."), + gray("Click to toggle.") + ) + )); + + setBackItem(BACK_ITEM_SLOT, new RailTypeMenu(getMenuPlayer(), false)); + getMenu().getSlot(SAVE_ITEM_SLOT).setItem(HeadFactory.head( + HeadTexture.CHECKMARK, + green(draft.getIdentifier() == null ? "Save Rail Type" : "Update Rail Type") + )); + + getMenu().open(getMenuPlayer()); + } + + @Override + protected void setMenuItemsAsync() { + // All editor items are rendered synchronously because they depend only on the local draft state. + } + + @Override + protected void setItemClickEventsAsync() { + getMenu().getSlot(NAME_SLOT).setClickHandler((clickPlayer, clickInformation) -> openNameEditor(clickPlayer)); + + setCounterClickEvents(TRACK_COUNT_SLOT, RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT, + draft::getTrackCount, draft::setTrackCount); + setCounterClickEvents(TRACK_SPACING_SLOT, RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING, + draft::getTrackSpacing, draft::setTrackSpacing); + getMenu().getSlot(TRACK_SPACING_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailTrackSpacingMenu(clickPlayer, draft, true); + }); + setCounterClickEvents(SLEEPER_SPACING_SLOT, RailType.MIN_SLEEPER_SPACING, RailType.MAX_SLEEPER_SPACING, + draft::getSleeperSpacing, draft::setSleeperSpacing); + + setBlockPickerClickEvents(RAIL_BLOCK_SLOT, RailBlockRole.RAIL_BLOCK); + setBlockPickerClickEvents(BLOCK_BELOW_SLOT, RailBlockRole.BLOCK_BELOW); + setBlockPickerClickEvents(SLEEPER_BLOCK_SLOT, RailBlockRole.SLEEPER_BLOCK); + setBlockPickerClickEvents(ICON_SLOT, RailBlockRole.ICON); + getMenu().getSlot(TRACK_SWITCHES_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + draft.setTrackSwitchesEnabled(!draft.isTrackSwitchesEnabled()); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + getMenu().getSlot(OVERHEAD_SETTINGS_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailOverheadWireMenu(clickPlayer, draft, true); + }); + + getMenu().getSlot(SAVE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> saveRailType(clickPlayer)); + } + + @Override + protected Mask getMask() { + return BinaryMask.builder(getMenu()) + .item(MenuItems.ITEM_BACKGROUND) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .build(); + } + + private void openNameEditor(Player clickPlayer) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new AnvilGUI.Builder() + .onClick((slot, stateSnapshot) -> { + if (slot != AnvilGUI.Slot.OUTPUT) + return List.of(); + + String displayName = stateSnapshot.getText().trim(); + + if (displayName.isEmpty()) { + stateSnapshot.getPlayer().sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Rail type name cannot be empty." + ))); + return List.of(AnvilGUI.ResponseAction.replaceInputText(getDisplayName())); + } + + draft.setDisplayName(displayName); + playSound(stateSnapshot.getPlayer(), Sound.UI_BUTTON_CLICK); + stateSnapshot.getPlayer().sendMessage(ChatHelper.getStandardComponent( + true, + "Rail type name set to '%s'.", + displayName + )); + return List.of( + AnvilGUI.ResponseAction.close(), + AnvilGUI.ResponseAction.run(() -> new RailTypeEditorMenu(clickPlayer, draft, true)) + ); + }) + .text(getDisplayName()) + .itemLeft(Item.create(Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Change Rail Type Name"))) + .title(darkGray("Change rail type name")) + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private void saveRailType(Player clickPlayer) { + String permission = draft.getIdentifier() == null ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; + + if (!Permissions.checkPermission(clickPlayer, permission)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailTypeManager railTypeManager = rail.getRailTypeManager(); + String identifier = draft.getIdentifier() == null ? railTypeManager.getNextCustomIdentifier() : draft.getIdentifier(); + String displayName = draft.getDisplayName() == null + ? railTypeManager.getNextCustomDisplayName() + : draft.getDisplayName(); + + RailType railType = RailType.createCustom(new RailType.Configuration() + .identifier(identifier) + .displayName(displayName) + .icon(draft.getIcon()) + .railBlocks(draft.getRailBlocks()) + .blocksBelow(draft.getBlocksBelow()) + .sleeperBlocks(draft.getSleeperBlocks()) + .sleeperSpacing(draft.getSleeperSpacing()) + .trackCount(draft.getTrackCount()) + .trackSpacing(draft.getTrackSpacing()) + .trackSpacings(draft.getTrackSpacings()) + .overheadPolesEnabled(draft.isOverheadPolesEnabled()) + .overheadPoleBlocks(draft.getOverheadPoleBlocks()) + .overheadSupportBlocks(draft.getOverheadSupportBlocks()) + .overheadPoleSpacing(draft.getOverheadPoleSpacing()) + .overheadPoleOffset(draft.getOverheadPoleOffset()) + .overheadPoleHeight(draft.getOverheadPoleHeight()) + .overheadWiresEnabled(draft.isOverheadWiresEnabled()) + .overheadWireBlocks(draft.getOverheadWireBlocks()) + .trackSwitchesEnabled(draft.isTrackSwitchesEnabled())); + + String error = railTypeManager.saveRailType(railType); + + if (error != null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent(error))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + Settings settings = rail.getPlayerSettings().get(clickPlayer.getUniqueId()); + + if (settings instanceof RailSettings railSettings) + railSettings.setValue(RailFlag.RAIL_TYPE, railType); + + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Saved rail type %s as '%s'.", + displayName, + identifier + )); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailTypeMenu(clickPlayer, true); + } + + private void setBlockPickerClickEvents(int slot, RailBlockRole role) { + getMenu().getSlot(slot).setClickHandler((clickPlayer, clickInformation) -> { + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailBlockPickerMenu(clickPlayer, draft, role, true); + }); + } + + private void setCounterClickEvents(int slot, int minValue, int maxValue, IntSupplier getter, IntConsumer setter) { + getMenu().getSlot(slot - 1).setClickHandler((clickPlayer, clickInformation) -> { + if (getter.getAsInt() <= minValue) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + setter.accept(getter.getAsInt() - 1); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + + getMenu().getSlot(slot + 1).setClickHandler((clickPlayer, clickInformation) -> { + if (getter.getAsInt() >= maxValue) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + setter.accept(getter.getAsInt() + 1); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + } + + private ItemStack createBlockItem(String name, List materials, String description) { + List lore = new java.util.ArrayList<>(); + lore.add(gray(description)); + for (XMaterial material : materials) + lore.add(white(RailTypeMenu.formatMaterial(material))); + lore.add(gray("Click to choose one or more materials.")); + Material icon = materials.getFirst().get(); + return Item.create(icon == null ? Material.BARRIER : icon, yellow(name), lore); + } + private ItemStack createBlockItem(String name, XMaterial material, String description) { + Material bukkitMaterial = material.get(); + + if (bukkitMaterial == null) + bukkitMaterial = Objects.requireNonNull(XMaterial.BARRIER.get()); + + return Item.create( + bukkitMaterial, + yellow(name + ": ") + white(RailTypeMenu.formatMaterial(material)), + List.of(gray(description), gray("Click to choose a different block.")) + ); + } + + private String getDisplayName() { + if (draft.getDisplayName() != null) + return draft.getDisplayName(); + + return draft.getIdentifier() == null ? "Custom Rail" : draft.getIdentifier(); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + + private static String green(String text) { + return RailMenuText.color(NamedTextColor.GREEN, text); + } + + private static String red(String text) { + return RailMenuText.color(NamedTextColor.RED, text); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java index 8bb5f6ad..7dea9c8e 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java @@ -1,61 +1,620 @@ package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; +import com.alpsbte.alpslib.utils.ChatHelper; import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.menu.GeneratorMenu; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; import net.buildtheearth.buildteamtools.utils.menus.NameListMenu; +import net.wesjd.anvilgui.AnvilGUI; +import net.kyori.adventure.text.format.NamedTextColor; import org.apache.commons.lang3.tuple.MutablePair; import org.bukkit.Sound; import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Objects; +import java.util.Set; public class RailTypeMenu extends NameListMenu { public static final String RAIL_TYPE_INV_NAME = "Choose a Rail Type"; + // Slots 30-32 belong to the page switcher, so the extra buttons sit next to it. + private static final int SEARCH_ITEM_SLOT = 28; + private static final int CREATE_ITEM_SLOT = 29; + private static final int RELOAD_ITEM_SLOT = 33; + private static final int BULK_DELETE_ITEM_SLOT = 34; + + private final String searchQuery; + private final Set selectedForDeletion; + public RailTypeMenu(Player player, boolean autoLoad) { - super(player, RAIL_TYPE_INV_NAME, getRailTypes(), new GeneratorMenu(player, false), autoLoad); + this(player, "", Set.of(), autoLoad); + } + + private RailTypeMenu(Player player, String searchQuery, Collection selectedForDeletion, boolean autoLoad) { + super(player, RAIL_TYPE_INV_NAME, getRailTypes(player, searchQuery), new GeneratorMenu(player, false), autoLoad); + this.searchQuery = searchQuery; + this.selectedForDeletion = new LinkedHashSet<>(selectedForDeletion); + preselectCurrentRailType(player); + } + + private void preselectCurrentRailType(Player player) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + Settings settings = rail.getPlayerSettings().get(player.getUniqueId()); + + if (!(settings instanceof RailSettings railSettings)) + return; + + Object value = railSettings.getValues().get(RailFlag.RAIL_TYPE); + + if (value instanceof RailType railType && RailType.byString(railType.getIdentifier()) != null) + selectedNames.add(railType.getIdentifier()); + else if (value instanceof String identifier && RailType.byString(identifier) != null) + selectedNames.add(identifier); } - private static @NonNull List> getRailTypes() { + private static @NonNull List> getRailTypes(Player player, String searchQuery) { List> railTypes = new ArrayList<>(); + Rail rail = GeneratorModule.getInstance().getRail(); - for (RailType railType : RailType.values()) { - railTypes.add(new MutablePair<>( - Item.create(Objects.requireNonNull(railType.getIcon().get()), railType.getDisplayName()), - railType.getIdentifier() - )); + if (rail == null) + return railTypes; + + String normalizedQuery = searchQuery.trim().toLowerCase(Locale.ROOT); + + for (RailType railType : rail.getRailTypeManager().getRailTypes()) { + if (!normalizedQuery.isEmpty() + && !railType.getIdentifier().toLowerCase(Locale.ROOT).contains(normalizedQuery) + && !railType.getDisplayName().toLowerCase(Locale.ROOT).contains(normalizedQuery)) + continue; + + railTypes.add(new MutablePair<>(RailTypeMenuItems.createRailTypeItem(railType, player), railType.getIdentifier())); } return railTypes; } + static String formatMaterials(List materials) { + return RailTypeMenuItems.formatMaterials(materials); + } + + static String formatMaterial(@Nullable XMaterial material) { + return RailTypeMenuItems.formatMaterial(material); + } + + @Override + protected void setPreviewItems() { + super.setPreviewItems(); + + setRailTypePageItems(); + + getMenu().getSlot(SEARCH_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.COMPASS.get()), + yellow("Search Rail Types"), + List.of( + gray("Current filter: ") + white(searchQuery.isBlank() ? "All" : searchQuery), + gray("Left-click to search."), + gray("Right-click to clear the filter.") + ) + )); + + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_CREATE)) + getMenu().getSlot(CREATE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NETHER_STAR.get()), + green("Create a Rail Type"), + List.of(gray("Configure and save a custom rail type.")) + )); + + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_EDIT)) + getMenu().getSlot(RELOAD_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.CLOCK.get()), + yellow("Reload Rail Types"), + List.of( + gray("Re-reads rail-types.yml from disk,"), + gray("so you can test changes without a restart.") + ) + )); + + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_DELETE)) + getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull((selectedForDeletion.isEmpty() ? XMaterial.GRAY_DYE : XMaterial.RED_DYE).get()), + selectedForDeletion.isEmpty() ? gray("Bulk Delete") : red("Delete Selected Rail Types"), + List.of( + gray("Selected: ") + white(String.valueOf(selectedForDeletion.size())), + gray("Shift+Left-Click custom types to select them."), + selectedForDeletion.isEmpty() + ? darkGray("No custom rail types selected.") + : red("Click to permanently delete all selected types.") + ) + )); + } + + @Override + protected void setPaginatedPreviewItems(@NonNull List source) { + List> pagItems = getPageItems(source); + int slot = 0; + + // Render the selection glow on a copy so deselected items lose their glow again. + for (MutablePair item : pagItems) { + RailType currentType = RailType.byString(item.getRight()); + ItemStack displayedItem = currentType == null ? item.getLeft() + : RailTypeMenuItems.createRailTypeItem(currentType, getMenuPlayer()); + + if (selectedNames.contains(item.getRight())) + displayedItem = RailTypeMenuItems.addSelectionGlow(displayedItem); + + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_DELETE) + && selectedForDeletion.contains(item.getRight())) + displayedItem = RailTypeMenuItems.addDeletionMarker(displayedItem); + + getMenu().getSlot(slot).setItem(displayedItem); + slot++; + } + } + + @Override + protected void setPaginatedItemClickEventsAsync(@NonNull List source) { + List> pagItems = getPageItems(source); + int slot = 0; + + // Rail types are mutually exclusive, so selecting one deselects the previous one. + for (MutablePair item : pagItems) { + final int _slot = slot; + getMenu().getSlot(_slot).setClickHandler((clickPlayer, clickInformation) -> { + String type = item.getRight().toLowerCase(); + RailType railType = RailType.byString(type); + if (railType == null) + return; + + if (clickInformation.getClickType() == ClickType.SHIFT_LEFT + && !railType.isBuiltIn() && clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) { + toggleBulkDeleteSelection(clickPlayer, type); + return; + } + + if (clickInformation.getClickType() == ClickType.SHIFT_RIGHT + && !railType.isBuiltIn() && clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) { + deleteCustomRailType(clickPlayer, type); + return; + } + + if (clickInformation.getClickType() == ClickType.DROP + && clickPlayer.hasPermission(Permissions.RAIL_TYPE_CREATE)) { + openEditorForRailType(clickPlayer, type, true); + return; + } + + if (clickInformation.getClickType() == ClickType.RIGHT + && clickPlayer.hasPermission(railType.isBuiltIn() + ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT)) { + openEditorForRailType(clickPlayer, type, railType.isBuiltIn()); + return; + } + + if (selectedNames.contains(type)) { + selectedNames.remove(type); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Deselected rail type '%s'.", + getRailTypeDisplayName(type) + )); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + } else { + selectedNames.clear(); + selectedNames.add(type); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Selected rail type '%s'.", + getRailTypeDisplayName(type) + )); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + } + + reloadMenuAsync(); + }); + slot++; + } + } + + private List> getPageItems(List source) { + List> items = new ArrayList<>(); + + for (Object item : source) { + if (item instanceof MutablePair pair + && pair.getLeft() instanceof ItemStack itemStack + && pair.getRight() instanceof String identifier) + items.add(new MutablePair<>(itemStack, identifier)); + } + + return items; + } + + private String getRailTypeDisplayName(String identifier) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return identifier; + + RailType railType = rail.getRailTypeManager().byString(identifier); + return railType == null ? identifier : railType.getDisplayName(); + } + + private void openEditorForRailType(Player clickPlayer, String identifier, boolean copy) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailType railType = rail.getRailTypeManager().byString(identifier); + + if (railType == null) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + String permission = copy ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; + + if (!Permissions.checkPermission(clickPlayer, permission)) + return; + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + copy + ? "Opening an editable copy of rail type '%s'." + : "Editing rail type '%s'.", + railType.getIdentifier() + )); + + // Built-in types cannot be overwritten, so editing one saves an editable copy instead. + new RailTypeEditorMenu(clickPlayer, RailTypeDraft.from(railType, !copy), true); + } + + private void deleteCustomRailType(Player clickPlayer, String identifier) { + if (!Permissions.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailType railType = rail.getRailTypeManager().byString(identifier); + + if (railType == null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "This rail type no longer exists." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + if (railType.isBuiltIn()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Built-in rail types cannot be deleted. Right-click to create an editable copy." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + String error = rail.getRailTypeManager().deleteRailType(railType.getIdentifier()); + + if (error != null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent(error))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + selectedNames.remove(railType.getIdentifier()); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Deleted rail type '%s'.", railType.getIdentifier())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailTypeMenu(clickPlayer, true); + } + + private void toggleBulkDeleteSelection(Player clickPlayer, String identifier) { + if (!clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailType railType = rail.getRailTypeManager().byString(identifier); + + if (railType == null || railType.isBuiltIn()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Only custom rail types can be selected for deletion." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + if (selectedForDeletion.remove(identifier)) + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Removed rail type '%s' from bulk deletion.", + identifier + )); + else + selectedForDeletion.add(identifier); + + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(false); + } + + private void deleteSelectedRailTypes(Player clickPlayer) { + if (selectedForDeletion.isEmpty()) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + if (!Permissions.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + int amount = selectedForDeletion.size(); + String error = rail.getRailTypeManager().deleteRailTypes(selectedForDeletion); + + if (error != null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent(error))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + selectedNames.removeAll(selectedForDeletion); + selectedForDeletion.clear(); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Deleted %s custom rail types.", amount)); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailTypeMenu(clickPlayer, searchQuery, selectedForDeletion, true); + } + @Override protected void setItemClickEventsAsync() { super.setItemClickEventsAsync(); + setRailTypePageClickEvents(); + + getMenu().getSlot(SEARCH_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (clickInformation.getClickType() == ClickType.RIGHT) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailTypeMenu(clickPlayer, "", selectedForDeletion, true); + return; + } + + openSearchEditor(clickPlayer); + }); + + getMenu().getSlot(CREATE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (!clickPlayer.hasPermission(Permissions.RAIL_TYPE_CREATE)) + return; + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Creating a new rail type.")); + + new RailTypeEditorMenu(clickPlayer, new RailTypeDraft(), true); + }); + + getMenu().getSlot(RELOAD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (!clickPlayer.hasPermission(Permissions.RAIL_TYPE_EDIT)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + rail.getRailTypeManager().reload(); + + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Reloaded %s rail types from rail-types.yml.", + rail.getRailTypeManager().getRailTypes().size() + )); + + new RailTypeMenu(clickPlayer, searchQuery, selectedForDeletion, true); + }); + + getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) + deleteSelectedRailTypes(clickPlayer); + }); + if (canProceed()) - getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { - Settings settings = GeneratorModule.getInstance().getRail().getPlayerSettings().get(clickPlayer.getUniqueId()); + getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + generateWithSelectedType(clickPlayer)); + } - if (!(settings instanceof RailSettings railSettings)) - return; + private void generateWithSelectedType(Player clickPlayer) { + if (selectedNames.isEmpty()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Select a rail type before generating." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + reloadMenuAsync(); + return; + } - railSettings.setValue(RailFlag.RAIL_TYPE, selectedNames.getFirst()); + Rail rail = GeneratorModule.getInstance().getRail(); - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); + if (rail == null) + return; - GeneratorModule.getInstance().getRail().generate(clickPlayer); - }); + Settings settings = rail.getPlayerSettings().get(clickPlayer.getUniqueId()); + + if (!(settings instanceof RailSettings railSettings)) + return; + + RailType railType = RailType.byString(selectedNames.getFirst()); + + if (railType == null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "This rail type no longer exists." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + railSettings.setValue(RailFlag.RAIL_TYPE, railType); + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + rail.generate(clickPlayer); + } + + private void openSearchEditor(Player clickPlayer) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new AnvilGUI.Builder() + .onClick((slot, stateSnapshot) -> { + if (slot != AnvilGUI.Slot.OUTPUT) + return List.of(); + + String query = stateSnapshot.getText().trim(); + return List.of( + AnvilGUI.ResponseAction.close(), + AnvilGUI.ResponseAction.run(() -> new RailTypeMenu( + clickPlayer, + query, + selectedForDeletion, + true + )) + ); + }) + .text(searchQuery.isBlank() ? "Search" : searchQuery) + .itemLeft(Item.create(Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Search Rail Types"))) + .title(darkGray("Search rail types")) + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private void setRailTypePageItems() { + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT - 1).setItem(createPreviousPageItem()); + + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.PAPER.get()), + yellow("Current Page ") + gray("- ") + white(String.valueOf(getPage())), + List.of(gray("Use the arrows next to this item to browse rail types.")) + )); + + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT + 1).setItem(createNextPageItem()); + } + + private ItemStack createPreviousPageItem() { + if (!hasPreviousPage()) + return Item.create( + Objects.requireNonNull(XMaterial.GRAY_STAINED_GLASS_PANE.get()), + gray("No Previous Page"), + List.of(darkGray("You are already on the first page.")) + ); + + return HeadFactory.head( + HeadTexture.WHITE_ARROW_LEFT, + yellow("Previous Page ") + gray("- ") + white(String.valueOf(getPage() - 1)), + new ArrayList<>(List.of(gray("Click to show earlier rail types."))) + ); + } + + private ItemStack createNextPageItem() { + if (!hasNextPage()) + return Item.create( + Objects.requireNonNull(XMaterial.GRAY_STAINED_GLASS_PANE.get()), + gray("No Next Page"), + List.of(darkGray("There are no more rail types.")) + ); + + return HeadFactory.head( + HeadTexture.WHITE_ARROW_RIGHT, + yellow("Next Page ") + gray("- ") + white(String.valueOf(getPage() + 1)), + new ArrayList<>(List.of(gray("Click to show more rail types."))) + ); + } + + private void setRailTypePageClickEvents() { + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT - 1).setClickHandler((clickPlayer, clickInformation) -> { + if (!hasPreviousPage()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "You are already on the first rail type page." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + previousPage(); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Opened rail type page %s.", getPage())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + }); + + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT + 1).setClickHandler((clickPlayer, clickInformation) -> { + if (!hasNextPage()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "There is no next rail type page." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + nextPage(); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Opened rail type page %s.", getPage())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + }); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + + private static String green(String text) { + return RailMenuText.color(NamedTextColor.GREEN, text); + } + + private static String red(String text) { + return RailMenuText.color(NamedTextColor.RED, text); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java new file mode 100644 index 00000000..38639d80 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java @@ -0,0 +1,237 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** Builds rail type icons and their visual selection states. */ +final class RailTypeMenuItems { + + private RailTypeMenuItems() { + } + + static ItemStack createRailTypeItem(RailType railType, Player player) { + Material icon = railType.getIcon().get(); + + if (icon == null) + icon = Objects.requireNonNull(XMaterial.RAIL.get()); + + List lore = createConfigurationLore(railType); + + lore.add(darkGray("Left-Click to select")); + if (railType.isBuiltIn() && player.hasPermission(Permissions.RAIL_TYPE_CREATE)) + lore.add(darkGray("Right-Click to create an editable copy")); + if (!railType.isBuiltIn() && player.hasPermission(Permissions.RAIL_TYPE_EDIT)) + lore.add(darkGray("Right-Click to edit")); + if (player.hasPermission(Permissions.RAIL_TYPE_CREATE)) + lore.add(darkGray("Press your drop key (Q) to create an editable copy")); + if (!railType.isBuiltIn() && player.hasPermission(Permissions.RAIL_TYPE_DELETE)) { + lore.add(darkGray("Shift+Right-Click to delete")); + lore.add(darkGray("Shift+Left-Click to select for bulk deletion")); + } + + return Item.create(icon, yellow(railType.getDisplayName()), lore); + } + + private static List createConfigurationLore(RailType railType) { + List lore = new ArrayList<>(); + lore.add(gray("Rail Block: ") + formatMaterials(railType.getRailBlocks())); + lore.add(gray("Blocks Below: ") + formatMaterials(railType.getBlocksBelow())); + addSleeperLore(lore, railType); + addTrackLore(lore, railType); + addOverheadLore(lore, railType); + lore.add(gray("Track Switches: ") + white(railType.isTrackSwitchesEnabled() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED)); + return lore; + } + + private static void addSleeperLore(List lore, RailType railType) { + if (!railType.hasSleepers()) { + lore.add(gray("Sleepers: ") + white("None")); + return; + } + + lore.add(gray("Sleepers: ") + + formatMaterials(railType.getSleeperBlocks()) + + gray(" every ") + + white(String.valueOf(railType.getSleeperSpacing())) + + gray(" blocks")); + } + + private static void addTrackLore(List lore, RailType railType) { + if (railType.getTrackCount() == 1) { + lore.add(gray("Tracks: ") + white("1")); + return; + } + + lore.add(gray("Tracks: ") + + white(String.valueOf(railType.getTrackCount())) + + gray(" with spacing ") + + white(railType.getTrackSpacings().toString())); + } + + private static void addOverheadLore(List lore, RailType railType) { + lore.add(gray("Overhead Poles: ") + white(railType.hasOverheadPoles() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED)); + + if (railType.hasOverheadPoles()) { + lore.add(gray("Pole Block: ") + formatMaterials(railType.getOverheadPoleBlocks())); + lore.add(gray("Top Support: ") + formatMaterials(railType.getOverheadSupportBlocks())); + lore.add(gray("Pole Spacing: ") + white(String.valueOf(railType.getOverheadPoleSpacing()))); + } + + lore.add(gray("Overhead Wires: ") + white(railType.hasOverheadWires() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED)); + if (railType.hasOverheadWires()) + lore.add(gray("Wire Blocks: ") + formatMaterials(railType.getOverheadWireBlocks())); + } + + static ItemStack addSelectionGlow(ItemStack item) { + ItemStack displayedItem = createGlintCompatibleDisplayItem(item); + displayedItem.setAmount(1); + displayedItem.addUnsafeEnchantment(Enchantment.LUCK_OF_THE_SEA, 1); + + ItemMeta meta = displayedItem.getItemMeta(); + + if (meta != null) { + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + forceEnchantmentGlint(meta); + displayedItem.setItemMeta(meta); + } + + return displayedItem; + } + + /** + * Vanilla does not render enchantment glint on block-entity item models, + * even when enchantment_glint_override is true (MC-69683). Use an enchanted + * book as the selected-state icon while retaining the rail type name, lore + * and the original icon name. + */ + private static ItemStack createGlintCompatibleDisplayItem(ItemStack item) { + if (!usesBlockEntityItemRenderer(item.getType())) + return item.clone(); + + ItemMeta originalMeta = item.getItemMeta(); + Component displayName = originalMeta != null && originalMeta.hasDisplayName() + ? Objects.requireNonNull(originalMeta.displayName()) + : Component.text("Selected Rail Type", NamedTextColor.YELLOW); + List originalLore = originalMeta == null ? null : originalMeta.lore(); + List lore = originalLore != null + ? new ArrayList<>(originalLore) + : new ArrayList<>(); + lore.add(Component.text("Original icon: ", NamedTextColor.GRAY) + .append(Component.text(formatMaterialName(item.getType()), NamedTextColor.WHITE))); + lore.add(Component.text("Minecraft cannot render glint on this block-entity icon.", NamedTextColor.DARK_GRAY)); + + ItemStack displayedItem = new ItemStack(Material.ENCHANTED_BOOK); + ItemMeta displayedMeta = displayedItem.getItemMeta(); + displayedMeta.displayName(displayName); + displayedMeta.lore(lore); + displayedItem.setItemMeta(displayedMeta); + return displayedItem; + } + + private static boolean usesBlockEntityItemRenderer(Material material) { + String name = material.name(); + return name.endsWith("_CHEST") + || name.endsWith("_SHULKER_BOX") + || name.endsWith("_HEAD") + || name.endsWith("_SKULL") + || name.endsWith("_BANNER") + || name.endsWith("_BED") + || material == Material.CONDUIT + || material == Material.DECORATED_POT; + } + + private static String formatMaterialName(Material material) { + return formatWords(material.name()); + } + + /** Paper 1.20.5+ exposes an explicit glint override; reflection preserves compatibility with older servers. */ + private static void forceEnchantmentGlint(ItemMeta meta) { + try { + ItemMeta.class + .getMethod("setEnchantmentGlintOverride", Boolean.class) + .invoke(meta, Boolean.TRUE); + } catch (ReflectiveOperationException ignored) { + // The hidden unsafe enchantment remains the fallback on older servers. + } + } + + static ItemStack addDeletionMarker(ItemStack item) { + ItemStack displayedItem = addSelectionGlow(item); + ItemMeta meta = displayedItem.getItemMeta(); + + if (meta == null) + return displayedItem; + + List existingLore = meta.lore(); + List lore = existingLore != null + ? new ArrayList<>(existingLore) + : new ArrayList<>(); + lore.add(Component.text("Selected for bulk deletion", NamedTextColor.RED)); + meta.lore(lore); + displayedItem.setItemMeta(meta); + return displayedItem; + } + + static String formatMaterials(List materials) { + return String.join(gray(", "), materials.stream() + .map(RailTypeMenuItems::formatMaterial) + .map(RailTypeMenuItems::white) + .toList()); + } + + static String formatMaterial(@Nullable XMaterial material) { + if (material == null) + return "None"; + + return formatWords(material.name()); + } + + private static String formatWords(String value) { + String[] words = value.toLowerCase(Locale.ROOT).split("_"); + List capitalizedWords = new ArrayList<>(); + + for (String word : words) + capitalizedWords.add(word.isEmpty() ? word : Character.toUpperCase(word.charAt(0)) + word.substring(1)); + + return String.join(" ", capitalizedWords); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java index 9cba7e1b..7e0c520d 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java @@ -1,5 +1,6 @@ package net.buildtheearth.buildteamtools.modules.generator.menu; +import com.alpsbte.alpslib.utils.ChatHelper; import com.alpsbte.alpslib.utils.item.Item; import com.cryptomorin.xseries.XMaterial; import net.buildtheearth.buildteamtools.modules.common.CommonModule; @@ -20,9 +21,11 @@ import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorCollections; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorType; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import net.buildtheearth.buildteamtools.utils.ListUtil; import net.buildtheearth.buildteamtools.utils.MenuItems; import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; @@ -37,6 +40,10 @@ public class GeneratorMenu extends AbstractMenu { public static final String GENERATOR_INV_NAME = "What do you want to generate?"; + private static final String DESCRIPTION_LABEL = color(NamedTextColor.YELLOW, "Description:"); + private static final String FEATURES_LABEL = color(NamedTextColor.YELLOW, "Features:"); + private static final String LEFT_CLICK_TO_GENERATE = color(NamedTextColor.DARK_GRAY, "Left-click to generate"); + private static final String RIGHT_CLICK_FOR_TUTORIAL = color(NamedTextColor.DARK_GRAY, "Right-click for Tutorial"); public static final int HOUSE_ITEM_SLOT = 9; public static final int ROAD_ITEM_SLOT = 11; @@ -52,113 +59,135 @@ public GeneratorMenu(Player player, boolean autoLoad) { protected void setPreviewItems() { ArrayList houseLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate basic building shells", "with multiple floors, windows and roofs", "", - "§eFeatures:", + FEATURES_LABEL, "- " + RoofType.values().length + " Roof Types", "- Custom Wall, Base and Roof Color", "- Custom Floor and Window Sizes", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack houseItem = Item.create(Objects.requireNonNull(XMaterial.BIRCH_DOOR.get()), "§cGenerate House", houseLore); + ItemStack houseItem = Item.create( + Objects.requireNonNull(XMaterial.BIRCH_DOOR.get()), + color(NamedTextColor.RED, "Generate House"), + houseLore + ); getMenu().getSlot(HOUSE_ITEM_SLOT).setItem(houseItem); ArrayList roadLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate roads and highways", "with multiple lanes and sidewalks", "", - "§eFeatures:", + FEATURES_LABEL, "- Custom Road Width and Color", "- Custom Sidewalk Width and Color", "- Custom Lane Count", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack roadItem = Item.create(Objects.requireNonNull(XMaterial.SMOOTH_STONE_SLAB.get()), "§bGenerate Road", roadLore); + ItemStack roadItem = Item.create( + Objects.requireNonNull(XMaterial.SMOOTH_STONE_SLAB.get()), + color(NamedTextColor.AQUA, "Generate Road"), + roadLore + ); getMenu().getSlot(ROAD_ITEM_SLOT).setItem(roadItem); ArrayList railwayLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate a predefined railway", "from your active WorldEdit selection", "", - "§eSupported selections:", + color(NamedTextColor.YELLOW, "Supported selections:"), "- Cuboid", "- Polygonal", "- Convex", "", - "§eFeatures:", + FEATURES_LABEL, "- Rail Type selection", "- Straight sections", "- Direction changes", "- Automatic side block orientation", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack railwayItem = Item.create(Objects.requireNonNull(XMaterial.RAIL.get()), "§9Generate Railway", railwayLore); + ItemStack railwayItem = Item.create( + Objects.requireNonNull(XMaterial.RAIL.get()), + color(NamedTextColor.BLUE, "Generate Railway"), + railwayLore + ); getMenu().getSlot(RAIL_ITEM_SLOT).setItem(railwayItem); if (!CommonModule.getInstance().getDependencyComponent().isSchematicBrushEnabled()) { ArrayList treeLore = ListUtil.createList( "", - "§cPlugin §eSchematicBrush §cis not installed", - "§cTree Generator is disabled", + color(NamedTextColor.RED, "Plugin ") + + color(NamedTextColor.YELLOW, "SchematicBrush ") + + color(NamedTextColor.RED, "is not installed"), + color(NamedTextColor.RED, "Tree Generator is disabled"), "", - "§8Leftclick for Installation Instructions" + color(NamedTextColor.DARK_GRAY, "Leftclick for Installation Instructions") ); - ItemStack treeItem = Item.create(Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), "§aGenerate Tree & Forest §c(DISABLED)", treeLore); + ItemStack treeItem = createTreeItem(color(NamedTextColor.RED, " (DISABLED)"), treeLore); getMenu().getSlot(TREE_ITEM_SLOT).setItem(treeItem); } else if (!GeneratorCollections.hasUpdatedGeneratorCollections(getMenuPlayer())) { ArrayList treeLore = ListUtil.createList( "", - "§cThe §eTree Pack " + Tree.TREE_PACK_VERSION + " §cis not installed", - "§cTree Generator is disabled", + color(NamedTextColor.RED, "The ") + + color(NamedTextColor.YELLOW, "Tree Pack " + Tree.TREE_PACK_VERSION + " ") + + color(NamedTextColor.RED, "is not installed"), + color(NamedTextColor.RED, "Tree Generator is disabled"), "", - "§8Leftclick for Installation Instructions" + color(NamedTextColor.DARK_GRAY, "Leftclick for Installation Instructions") ); - ItemStack treeItem = Item.create(Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), "§aGenerate Tree & Forest §c(DISABLED)", treeLore); + ItemStack treeItem = createTreeItem(color(NamedTextColor.RED, " (DISABLED)"), treeLore); getMenu().getSlot(TREE_ITEM_SLOT).setItem(treeItem); } else { ArrayList treeLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate trees from a set of", "hundreds of different types", "", - "§eFeatures:", + FEATURES_LABEL, "- Custom Tree Type", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack treeItem = Item.create(Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), "§aGenerate Tree & Forest", treeLore); + ItemStack treeItem = createTreeItem("", treeLore); getMenu().getSlot(TREE_ITEM_SLOT).setItem(treeItem); } ArrayList fieldLore = ListUtil.createList( "", - "§cThis §eGenerator §cis currently broken", - "§cField Generator is disabled", + color(NamedTextColor.RED, "This ") + + color(NamedTextColor.YELLOW, "Generator ") + + color(NamedTextColor.RED, "is currently broken"), + color(NamedTextColor.RED, "Field Generator is disabled"), "", - "§8If you want to help fixing ask on Dev Hub!" + color(NamedTextColor.DARK_GRAY, "If you want to help fixing ask on Dev Hub!") ); - ItemStack fieldItem = Item.create(Objects.requireNonNull(XMaterial.WHEAT.get()), "§6Generate Field §c(DISABLED)", fieldLore); + ItemStack fieldItem = Item.create( + Objects.requireNonNull(XMaterial.WHEAT.get()), + color(NamedTextColor.GOLD, "Generate Field ") + color(NamedTextColor.RED, "(DISABLED)"), + fieldLore + ); getMenu().getSlot(FIELD_ITEM_SLOT).setItem(fieldItem); super.setPreviewItems(); @@ -171,77 +200,98 @@ protected void setMenuItemsAsync() { @Override protected void setItemClickEventsAsync() { - getMenu().getSlot(HOUSE_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.HOUSE); - return; - } - - House house = GeneratorModule.getInstance().getHouse(); - house.getPlayerSettings().put(clickPlayer.getUniqueId(), new HouseSettings(clickPlayer)); - - if (!house.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new WallColorMenu(clickPlayer, true); - })); - - getMenu().getSlot(ROAD_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.ROAD); - return; - } - - Road road = GeneratorModule.getInstance().getRoad(); - road.getPlayerSettings().put(clickPlayer.getUniqueId(), new RoadSettings(clickPlayer)); - - if (!road.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new RoadColorMenu(clickPlayer, true); - })); - - getMenu().getSlot(RAIL_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.RAIL); - return; - } - - Rail rail = GeneratorModule.getInstance().getRail(); - rail.getPlayerSettings().put(clickPlayer.getUniqueId(), new RailSettings(clickPlayer)); - - if (!rail.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new RailTypeMenu(clickPlayer, true); - })); - - getMenu().getSlot(TREE_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.TREE); - return; - } - - Tree tree = GeneratorModule.getInstance().getTree(); - tree.getPlayerSettings().put(clickPlayer.getUniqueId(), new TreeSettings(clickPlayer)); - - if (!tree.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new TreeTypeMenu(clickPlayer, true); - })); - - getMenu().getSlot(FIELD_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - sendMoreInformation(clickPlayer, GeneratorType.FIELD); - })); + getMenu().getSlot(HOUSE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleHouseClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(ROAD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleRoadClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(RAIL_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleRailClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(TREE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleTreeClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(FIELD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + sendMoreInformation(clickPlayer, GeneratorType.FIELD)); + } + + private void handleHouseClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.HOUSE)) + return; + + House house = GeneratorModule.getInstance().getHouse(); + house.getPlayerSettings().put(player.getUniqueId(), new HouseSettings(player)); + + if (!house.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new WallColorMenu(player, true); + } + + private void handleRoadClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.ROAD)) + return; + + Road road = GeneratorModule.getInstance().getRoad(); + road.getPlayerSettings().put(player.getUniqueId(), new RoadSettings(player)); + + if (!road.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new RoadColorMenu(player, true); + } + + private void handleRailClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.RAIL) + || !Permissions.checkPermission(player, Permissions.RAIL_TYPE_MENU)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + rail.getPlayerSettings().put(player.getUniqueId(), new RailSettings(player)); + + if (!rail.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new RailTypeMenu(player, true); + } + + private void handleTreeClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.TREE)) + return; + + Tree tree = GeneratorModule.getInstance().getTree(); + tree.getPlayerSettings().put(player.getUniqueId(), new TreeSettings(player)); + + if (!tree.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new TreeTypeMenu(player, true); + } + + private boolean showTutorialForRightClick(Player player, ClickType clickType, GeneratorType generatorType) { + if (clickType != ClickType.RIGHT) + return false; + + sendMoreInformation(player, generatorType); + return true; + } + + private void closeMenuWithClickSound(Player player) { + player.closeInventory(); + player.playSound(player, Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); + } + + private ItemStack createTreeItem(String suffix, ArrayList lore) { + return Item.create( + Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), + color(NamedTextColor.GREEN, "Generate Tree & Forest") + suffix, + lore + ); + } + + private static String color(NamedTextColor color, String text) { + return ChatHelper.getColorizedString(color, text, false); } private void sendMoreInformation(@NonNull Player clickPlayer, @NonNull GeneratorType generator) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java index d42f21b7..3914f724 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java @@ -7,7 +7,7 @@ import net.buildtheearth.buildteamtools.modules.generator.components.field.CropStage; import net.buildtheearth.buildteamtools.modules.generator.components.field.CropType; import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.components.tree.TreeType; import net.buildtheearth.buildteamtools.modules.generator.components.tree.TreeWidth; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java index 1b894500..4736006e 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java @@ -7,7 +7,7 @@ import net.buildtheearth.buildteamtools.modules.generator.components.field.CropStage; import net.buildtheearth.buildteamtools.modules.generator.components.field.CropType; import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.components.tree.TreeWidth; import org.bukkit.block.Block; import org.bukkit.entity.Player; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java b/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java index b9e2751f..a797ebda 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java @@ -109,7 +109,7 @@ private boolean handleMigrateCommand(@NonNull Player player, String @NonNull [] private boolean handleRandomWarpCommand(@NonNull Player player, String @NonNull [] args) { if (!player.hasPermission(Permissions.WARP_RANDOM)) { - Utils.sendNoPermissionMessage(player, Permissions.WARP_RANDOM); + Permissions.sendNoPermissionMessage(player, Permissions.WARP_RANDOM); return true; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java b/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java index 4d07d73d..a791818f 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java @@ -1,6 +1,8 @@ package net.buildtheearth.buildteamtools.modules.network.model; import lombok.experimental.UtilityClass; +import org.bukkit.command.CommandSender; +import com.alpsbte.alpslib.utils.ChatHelper; @UtilityClass public class Permissions { @@ -15,6 +17,12 @@ public class Permissions { public static final String GENERATOR_USE = "btt.generator.use"; + public static final String RAIL_GENERATOR_USE = "btt.generator.rail.use"; + public static final String RAIL_TYPE_MENU = "btt.generator.rail.menu"; + public static final String RAIL_TYPE_CREATE = "btt.generator.rail.create"; + public static final String RAIL_TYPE_EDIT = "btt.generator.rail.edit"; + public static final String RAIL_TYPE_DELETE = "btt.generator.rail.delete"; + public static final String RAIL_MULTIPLE_TRACKS = "btt.generator.rail.multiple"; public static final String BLOCK_PALETTE_EDIT = "btt.bp.edit"; @@ -38,4 +46,17 @@ public class Permissions { public static final String AUTO_TPLL = "btt.global.autotpll"; + /** Checks permission and sends the standard denial message when access is denied. */ + public static boolean checkPermission(CommandSender sender, String permission) { + if (sender.hasPermission(permission)) + return true; + + sendNoPermissionMessage(sender, permission); + return false; + } + + public static void sendNoPermissionMessage(CommandSender sender, String permission) { + sender.sendMessage(ChatHelper.getErrorComponent("You don't have permission to execute this command. Required " + + "permission: " + permission)); + } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java b/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java index 8da47c61..a4cab0e8 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java +++ b/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java @@ -1,7 +1,5 @@ package net.buildtheearth.buildteamtools.utils; -import com.alpsbte.alpslib.utils.ChatHelper; -import org.bukkit.command.CommandSender; import java.util.ArrayList; import java.util.List; @@ -133,8 +131,4 @@ public static List getTabCompleterArgs(String[] args, String parentArg, return null; } - public static void sendNoPermissionMessage(CommandSender sender, String permission) { - sender.sendMessage(ChatHelper.getErrorComponent("You don't have permission to execute this command. Required " + - "permission: " + permission)); - } } diff --git a/src/main/resources/modules/generator/config.yml b/src/main/resources/modules/generator/config.yml index 86e13d9f..0aaa5301 100644 --- a/src/main/resources/modules/generator/config.yml +++ b/src/main/resources/modules/generator/config.yml @@ -7,12 +7,12 @@ # ---------------------------------------------------------------------------------------------- rail: - # Conservative defaults for a 4 GB server. Lower these if rail generation is too heavy for your server. - max-control-points: 1000 - max-path-points: 24000 - max-block-placements: 150000 - max-prepared-region-volume: 1500000 - max-prepared-region-axis-length: 1024 + # Upper safe defaults. Lower these if rail generation is too heavy for your server. + max-control-points: 2000 + max-path-points: 75000 + max-block-placements: 300000 + max-prepared-region-volume: 6000000 + max-prepared-region-axis-length: 2048 block-placement-batch-size: 750 # NOTE: Do not change diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9306cb41..7ce8bb75 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -58,6 +58,9 @@ permissions: default: op children: btt.generator.use: true + btt.generator.rail.use: true + btt.generator.rail.menu: true + btt.generator.rail.multiple: true btt.permpack.admin: description: Contains all permissions for admins default: op @@ -77,6 +80,28 @@ permissions: btt.warp.group.delete: true btt.notify.update: true btt.bp.edit: true + btt.generator.rail.create: true + btt.generator.rail.edit: true + btt.generator.rail.delete: true + + btt.generator.rail.use: + description: Allows players to use the rail generator + default: op + btt.generator.rail.menu: + description: Allows players to open the rail type menu + default: op + btt.generator.rail.create: + description: Allows players to create custom rail types + default: op + btt.generator.rail.edit: + description: Allows players to edit custom rail types + default: op + btt.generator.rail.delete: + description: Allows players to delete custom rail types + default: op + btt.generator.rail.multiple: + description: Allows players to generate multiple parallel tracks + default: op btt.bp.use: description: Allows players to use /blockpalette diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java new file mode 100644 index 00000000..0011982c --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java @@ -0,0 +1,55 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import com.cryptomorin.xseries.XMaterial; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RailPaletteStorageTest { + @Test + void saveValidationReceivesAllPalettesWithoutCollapsingToTheirFirstMaterial() { + List mix = List.of(XMaterial.STONE, XMaterial.COBBLESTONE); + RailType original = RailType.createCustom(new RailType.Configuration() + .railBlocks(mix).sleeperBlocks(mix).overheadPoleBlocks(mix) + .overheadSupportBlocks(mix).overheadWireBlocks(mix)); + RailType.Configuration saved = RailTypeManager.toConfiguration(original); + assertEquals(mix, saved.railBlocks()); + assertEquals(mix, saved.sleeperBlocks()); + assertEquals(mix, saved.overheadPoleBlocks()); + assertEquals(mix, saved.overheadSupportBlocks()); + assertEquals(mix, saved.overheadWireBlocks()); + } + + @Test + void acceptsLegacyScalarAndPreservesPaletteOrderAndWeights() { + assertEquals(List.of(XMaterial.ANVIL), RailTypeManager.parsePalette("ANVIL")); + assertEquals(List.of(XMaterial.ANVIL, XMaterial.CHIPPED_ANVIL, XMaterial.ANVIL), + RailTypeManager.parsePalette(List.of("ANVIL", "CHIPPED_ANVIL", "ANVIL"))); + assertEquals(List.of(), RailTypeManager.parsePalette(null)); + assertNull(RailTypeManager.parsePalette(List.of("NOT_A_BLOCK")).getFirst()); + assertNull(RailTypeManager.parsePalette(List.of(123)).getFirst()); + } + + @Test + void migrationConvertsEveryScalarAndPreservesExistingMixes() throws Exception { + YamlConfiguration config = new YamlConfiguration(); + List paths = List.of("rail-block", "sleeper-block", "overhead.poles.block", + "overhead.support.block", "overhead.wires.block"); + for (String path : paths) + config.set("rail-types.example." + path, "STONE"); + config.set("rail-types.example.blocks-below", List.of("GRAVEL", "STONE", "GRAVEL")); + config.set("rail-types.mixed.rail-block", List.of("ANVIL", "CHIPPED_ANVIL")); + + RailTypeManager.migrateVersionFourToFive(config); + RailTypeManager.migrateVersionFourToFive(config); + YamlConfiguration reloaded = new YamlConfiguration(); + reloaded.loadFromString(config.saveToString()); + for (String path : paths) + assertEquals(List.of(XMaterial.STONE), RailTypeManager.parsePalette(reloaded.get("rail-types.example." + path))); + assertEquals(List.of("GRAVEL", "STONE", "GRAVEL"), reloaded.getStringList("rail-types.example.blocks-below")); + assertEquals(List.of("ANVIL", "CHIPPED_ANVIL"), reloaded.getStringList("rail-types.mixed.rail-block")); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java new file mode 100644 index 00000000..56986cac --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java @@ -0,0 +1,22 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RailTypeNamingTest { + @Test + void renamedFirstCopyFreesItsDisplayName() { + assertEquals("Custom Rail 2", RailTypeManager.getNextCustomDisplayName(List.of("Standard", "Custom Rail 1"))); + assertEquals("Custom Rail 1", RailTypeManager.getNextCustomDisplayName(List.of("Standard", "Regional Railway"))); + } + + @Test + void reusesLowestGapWithoutCaseInsensitiveCollisions() { + assertEquals("Custom Rail 2", RailTypeManager.getNextCustomDisplayName( + List.of("CUSTOM RAIL 1", "Custom Rail 3", "Custom Rail 4"))); + assertEquals("Custom Rail 1", RailTypeManager.getNextCustomDisplayName(List.of())); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java new file mode 100644 index 00000000..eeacb98e --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java @@ -0,0 +1,35 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class RailMaterialPaletteTest { + @Test + void eachAxisUsesTheWholeMixAndRepeatedPositionsStayStable() { + List palette = List.of("a", "b", "c"); + for (int axis = 0; axis < 3; axis++) { + Set seen = new HashSet<>(); + for (int coordinate = -128; coordinate <= 128; coordinate++) { + int x = axis == 0 ? coordinate : 0; + int y = axis == 1 ? coordinate : 0; + int z = axis == 2 ? coordinate : 0; + String selected = RailMaterialPalette.select(palette, x, y, z); + assertEquals(selected, RailMaterialPalette.select(palette, x, y, z)); + seen.add(selected); + } + assertEquals(Set.copyOf(palette), seen); + } + } + + @Test + void handlesSingleMaterialExtremeCoordinatesAndEmptyPalette() { + assertEquals("only", RailMaterialPalette.select(List.of("only"), Integer.MIN_VALUE, 0, Integer.MAX_VALUE)); + assertTrue(List.of("a", "b").contains(RailMaterialPalette.select(List.of("a", "b"), Integer.MIN_VALUE, Integer.MAX_VALUE, -1))); + assertThrows(IllegalArgumentException.class, () -> RailMaterialPalette.select(List.of(), 0, 0, 0)); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java new file mode 100644 index 00000000..ede9f8c0 --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java @@ -0,0 +1,128 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RailTypeDraftTest { + @Test + void everyBuildingRoleSupportsAnIndependentPaletteWhileIconStaysSingle() { + List palette = List.of(XMaterial.STONE, XMaterial.COBBLESTONE); + RailType source = RailType.createCustom(new RailType.Configuration() + .railBlocks(palette).sleeperBlocks(palette).overheadPoleBlocks(palette) + .overheadSupportBlocks(palette).overheadWireBlocks(palette).blocksBelow(palette) + .icon(XMaterial.RAIL).trackCount(1).trackSpacing(5)); + RailTypeDraft draft = RailTypeDraft.from(source, false); + for (RailBlockRole role : RailBlockRole.values()) { + if (role == RailBlockRole.ICON) + continue; + assertEquals(palette, role.getSelected(draft)); + List selection = new ArrayList<>(List.of(XMaterial.ANVIL, XMaterial.CHIPPED_ANVIL)); + role.apply(draft, selection); + selection.clear(); + assertEquals(List.of(XMaterial.ANVIL, XMaterial.CHIPPED_ANVIL), role.getSelected(draft)); + } + assertEquals(List.of(XMaterial.RAIL), RailBlockRole.ICON.getSelected(draft)); + assertEquals(palette, source.getRailBlocks()); + assertEquals(palette, source.getSleeperBlocks()); + assertEquals(palette, source.getOverheadPoleBlocks()); + assertEquals(palette, source.getOverheadSupportBlocks()); + assertEquals(palette, source.getOverheadWireBlocks()); + } + + private RailType source() { + return RailType.createCustom(new RailType.Configuration() + .identifier("custom-1").displayName("Regional Railway") + .icon(XMaterial.RAIL).railBlock(XMaterial.ANVIL) + .blocksBelow(List.of(XMaterial.DEAD_FIRE_CORAL_BLOCK, XMaterial.STONE, XMaterial.COBBLESTONE)) + .sleeperBlock(XMaterial.SPRUCE_PLANKS).sleeperSpacing(4) + .trackCount(3).trackSpacing(5).trackSpacings(List.of(5, 8)) + .overheadPolesEnabled(true).overheadPoleBlock(XMaterial.LIGHT_GRAY_CONCRETE) + .overheadSupportBlock(XMaterial.STONE).overheadPoleSpacing(20) + .overheadPoleOffset(4).overheadPoleHeight(7) + .overheadWiresEnabled(true).overheadWireBlock(XMaterial.IRON_BARS) + .trackSwitchesEnabled(true)); + } + + @Test + void copyingCustomTypePreservesConfigurationWithoutReusingIdentity() { + RailType source = source(); + RailTypeDraft copy = RailTypeDraft.from(source, false); + assertNull(copy.getIdentifier()); + assertNull(copy.getDisplayName()); + assertEquals(source.getBlocksBelow(), copy.getBlocksBelow()); + assertEquals(source.getRailBlock(), copy.getRailBlock()); + assertEquals(source.getTrackSpacings(), copy.getTrackSpacings()); + assertEquals(source.getSleeperSpacing(), copy.getSleeperSpacing()); + assertEquals(source.getOverheadPoleSpacing(), copy.getOverheadPoleSpacing()); + assertEquals(source.getOverheadSupportBlock(), copy.getOverheadSupportBlock()); + assertEquals(source.isTrackSwitchesEnabled(), copy.isTrackSwitchesEnabled()); + + copy.setTrackSpacing(0, 9); + assertEquals(List.of(5, 8), source.getTrackSpacings()); + } + + @Test + void editingPreservesIdentityAndEntirePalette() { + RailType source = source(); + RailTypeDraft edit = RailTypeDraft.from(source, true); + assertEquals(source.getIdentifier(), edit.getIdentifier()); + assertEquals(source.getDisplayName(), edit.getDisplayName()); + assertEquals(source.getBlocksBelow(), RailBlockRole.BLOCK_BELOW.getSelected(edit)); + RailBlockRole.RAIL_BLOCK.apply(edit, List.of(XMaterial.CHIPPED_ANVIL)); + assertEquals(XMaterial.CHIPPED_ANVIL, edit.getRailBlock()); + assertEquals(source.getBlocksBelow(), edit.getBlocksBelow()); + } + + @Test + void applyingPaletteKeepsAllMaterialsAndIsolatesSelectionChanges() { + RailTypeDraft draft = RailTypeDraft.from(source(), true); + List selection = new ArrayList<>(List.of(XMaterial.GRAVEL, XMaterial.STONE)); + RailBlockRole.BLOCK_BELOW.apply(draft, selection); + selection.clear(); + assertEquals(List.of(XMaterial.GRAVEL, XMaterial.STONE), draft.getBlocksBelow()); + } + + private RailType mixedType() { + return RailType.createCustom(new RailType.Configuration() + .identifier("mixed").displayName("Mixed ballast") + .blocksBelow(List.of(XMaterial.GRAVEL, XMaterial.STONE, XMaterial.GRAVEL)) + .trackCount(2).trackSpacing(5)); + } + + @Test + void unrelatedEditsPreserveBallastOrderAndWeights() { + RailType original = mixedType(); + RailTypeDraft draft = RailTypeDraft.from(original, true); + draft.setDisplayName("Renamed railway"); + draft.setTrackCount(3); + draft.setOverheadWiresEnabled(true); + + assertEquals(original.getBlocksBelow(), draft.getBlocksBelow()); + assertEquals("mixed", draft.getIdentifier()); + } + + @Test + void copyingATypePreservesTheEntireMix() { + RailType original = mixedType(); + RailTypeDraft draft = RailTypeDraft.from(original, false); + + assertNull(draft.getIdentifier()); + assertEquals(original.getBlocksBelow(), draft.getBlocksBelow()); + } + + @Test + void choosingBallastExplicitlyReplacesTheMix() { + RailType original = mixedType(); + RailTypeDraft draft = RailTypeDraft.from(original, true); + RailBlockRole.BLOCK_BELOW.apply(draft, XMaterial.STONE); + + assertEquals(List.of(XMaterial.STONE), draft.getBlocksBelow()); + assertEquals(3, original.getBlocksBelow().size()); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java new file mode 100644 index 00000000..f151fc87 --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java @@ -0,0 +1,41 @@ +package net.buildtheearth.buildteamtools.modules.network.model; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class PermissionsTest { + private CommandSender sender(boolean allowed, List messages) { + return (CommandSender) Proxy.newProxyInstance(CommandSender.class.getClassLoader(), + new Class[]{CommandSender.class}, (proxy, method, args) -> { + if (method.getName().equals("hasPermission")) + return allowed; + if (method.getName().equals("sendMessage")) { + for (Object arg : args) + if (arg instanceof Component component) + messages.add(component); + return null; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + + @Test + void permissionCheckUsesTheExistingDenialMessageAndAcceptsAnyCommandSender() { + List messages = new ArrayList<>(); + assertTrue(Permissions.checkPermission(sender(true, messages), Permissions.RAIL_TYPE_EDIT)); + assertTrue(messages.isEmpty()); + assertFalse(Permissions.checkPermission(sender(false, messages), Permissions.RAIL_TYPE_EDIT)); + assertEquals(1, messages.size()); + String denial = PlainTextComponentSerializer.plainText().serialize(messages.getFirst()); + assertTrue(denial.contains(Permissions.RAIL_TYPE_EDIT)); + assertTrue(denial.contains("You don't have permission to execute this command.")); + } +}