From 9430955fcee7d532698fbc7fbf263f09b6fdfebc Mon Sep 17 00:00:00 2001 From: Jasupa Date: Tue, 7 Jul 2026 12:46:17 +0200 Subject: [PATCH 1/2] feat(rail): add configurable rail generation --- .../generator/components/rail/Rail.java | 14 +- .../components/rail/RailBlockBuilder.java | 107 +++++-- .../generator/components/rail/RailFlag.java | 4 +- .../components/rail/RailLanePathBuilder.java | 24 +- .../generator/components/rail/RailLimits.java | 4 +- .../rail/RailPreparationProgress.java | 58 ++-- .../components/rail/RailScripts.java | 80 +++-- .../components/rail/RailSettings.java | 2 +- .../generator/components/rail/RailType.java | 284 +++++++++++++++++- .../components/rail/RailTypeManager.java | 249 +++++++++++++++ 10 files changed, 719 insertions(+), 107 deletions(-) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTypeManager.java 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..2a4ef0ea 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,6 +6,8 @@ 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.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorType; @@ -20,8 +22,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 +44,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; } @@ -67,6 +73,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 index b2e130e8..78e7a878 100644 --- 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 @@ -1,9 +1,11 @@ package net.buildtheearth.buildteamtools.modules.generator.components.rail; import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.alpsbte.alpslib.utils.item.Item; import com.cryptomorin.xseries.XMaterial; 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 org.bukkit.util.Vector; @@ -17,45 +19,34 @@ 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 static final String FACING_PROPERTY = "facing"; + // 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 List controlPoints; private final RailTerrainResolver terrainResolver; private final RailType railType; + private final BlockType railBlockType; 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.railBlockType = getRailBlockType(railType); this.preparationProgress = preparationProgress; - this.railLaneCount = railLaneCount; - this.railLaneSpacing = railLaneSpacing; this.terrainAdjustedPercentage = terrainAdjustedPercentage; this.buildFinishedPercentage = buildFinishedPercentage; } - Map build(List path) { + Map build(List> railCenterPaths) { 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); @@ -76,16 +67,21 @@ Map build(List path) { int processedSideBlocks = 0; for (RailSideBlock sideBlock : sideBlocks.values()) { - railBlocks.put(sideBlock.key(), createAnvilBlockState(resolveSideBlockFacing(sideBlock, sideBlocks))); + railBlocks.put(sideBlock.key(), createRailBlockState(resolveSideBlockFacing(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 (Vector center : railCenterPath) { - railBlocks.put(PositionKey.from(center), createCenterBlockState(center)); + 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)); } @@ -94,6 +90,50 @@ Map build(List path) { return railBlocks; } + private boolean isSleeperPoint(int index) { + return railType.hasSleepers() && index % railType.getSleeperSpacing() == 0; + } + + private void addSleeperBlocks(Map railBlocks, List path, Set centerPositions) { + BlockState sleeperBlockState = GeneratorUtils.getBlockState(railType.getSleeperBlock()); + + 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, sleeperBlockState, centerPositions); + addSleeperBlock(railBlocks, center, perpendicularStep, -SLEEPER_SIDE_OFFSET, sleeperBlockState, centerPositions); + } + } + + private void addSleeperBlock( + Map railBlocks, + Vector center, + RailStep perpendicularStep, + int offset, + BlockState sleeperBlockState, + 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, sleeperBlockState); + } + private int getTotalPathPointCount(List> railCenterPaths) { int totalPathPoints = 0; @@ -247,22 +287,29 @@ private RailStep getStep(Vector from, Vector to) { return new RailStep(dx, dz); } - private BlockState createCenterBlockState(Vector position) { - return switch (railType) { - case STANDARD -> createStandardCenterBlockState(position); - }; - } + private BlockState createCenterBlockState(Vector position, boolean sleeperPoint) { + if (sleeperPoint) + return GeneratorUtils.getBlockState(railType.getSleeperBlock()); - private BlockState createStandardCenterBlockState(Vector position) { + List blocksBelow = railType.getBlocksBelow(); int index = Math.floorMod( position.getBlockX() * 31 + position.getBlockY() * 23 + position.getBlockZ() * 17, - CENTER_MATERIALS.length + blocksBelow.size() ); - return GeneratorUtils.getBlockState(CENTER_MATERIALS[index]); + return GeneratorUtils.getBlockState(blocksBelow.get(index)); } - private BlockState createAnvilBlockState(Direction direction) { - return GeneratorUtils.getBlockStateWithFacing(BlockTypes.ANVIL, direction); + private BlockState createRailBlockState(Direction direction) { + if (!railBlockType.getPropertyMap().containsKey(FACING_PROPERTY)) + return railBlockType.getDefaultState(); + + return GeneratorUtils.getBlockStateWithFacing(railBlockType, direction); + } + + private BlockType getRailBlockType(RailType railType) { + BlockType blockType = Item.convertXMaterialToWEBlockType(railType.getRailBlock()); + + return blockType == null ? BlockTypes.ANVIL : blockType; } } 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 index 62f2ae9d..bd24386b 100644 --- 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 @@ -26,22 +26,22 @@ List> createRailCenterPaths(List path) { return List.of(path); List> railCenterPaths = new ArrayList<>(); - int sideLaneCount = (railLaneCount - 1) / 2; + double centerLaneIndex = (railLaneCount - 1) / 2.0; - for (int laneIndex = sideLaneCount; laneIndex >= 1; laneIndex--) { - List leftLane = createShiftedRailLane(laneIndex * railLaneSpacing, 1); + // 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 = (int) Math.round((laneIndex - centerLaneIndex) * railLaneSpacing); - if (leftLane.size() >= 2) - railCenterPaths.add(leftLane); - } - - railCenterPaths.add(path); + if (offset == 0) { + railCenterPaths.add(path); + continue; + } - for (int laneIndex = 1; laneIndex <= sideLaneCount; laneIndex++) { - List rightLane = createShiftedRailLane(laneIndex * railLaneSpacing, -1); + List shiftedLane = createShiftedRailLane(Math.abs(offset), offset > 0 ? 1 : -1); - if (rightLane.size() >= 2) - railCenterPaths.add(rightLane); + if (shiftedLane.size() >= 2) + railCenterPaths.add(shiftedLane); } return railCenterPaths; 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/RailLimits.java index 860fd0ae..e287fecb 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/RailLimits.java @@ -54,10 +54,10 @@ static RailLimits fromConfig() { } 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/RailPreparationProgress.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java index f653b082..b744170d 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/RailPreparationProgress.java @@ -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/RailScripts.java index 1d055b74..a5f8e486 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/RailScripts.java @@ -42,14 +42,13 @@ 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 int trackSpacing = RailType.DEFAULT_TRACK_SPACING; private final RailLimits limits; private final RailPreparationProgress preparationProgress; private final Runnable preparationFinishedCallback; @@ -90,6 +89,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); @@ -141,7 +144,6 @@ private boolean prepareSession() { snapMissingControlPointHeightsToTerrain(controlPoints); centerPath = createCenterPath(controlPoints); adjustCenterPathToTerrain(); - railType = getRailType(); preparationProgress.completeStage(TERRAIN_ADJUST_PROGRESS); return true; @@ -154,7 +156,20 @@ 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, trackSpacing) + .createRailCenterPaths(centerPath); + + if (railCenterPaths.size() < trackCount) { + sendRailError( + "Rail Generator could not fit %s parallel tracks with %s blocks of spacing in this selection. " + + "Reduce the track count or spacing, or make the selection longer and less curved.", + trackCount, + trackSpacing + ); + return false; + } + + Map railBlocks = buildRailBlocks(railCenterPaths); preparationProgress.completeStage(RAIL_BLOCK_BUILD_PROGRESS); if (railBlocks.size() > limits.maxBlockPlacements()) { @@ -256,7 +271,7 @@ private boolean hasValidCenterPath() { } private boolean hasSafeEstimatedBlockCount(List path) { - long estimatedBlocks = (long) path.size() * getRailLaneCount() * 5L; + long estimatedBlocks = (long) path.size() * trackCount * 5L; if (estimatedBlocks <= limits.maxBlockPlacements()) return true; @@ -358,33 +373,58 @@ private List createRailSelectionPoints(List points) { } private int getSelectionPadding() { - int sideLaneCount = (getRailLaneCount() - 1) / 2; - return SELECTION_PADDING + (getRailLaneSpacing() * sideLaneCount) + 2; + int maxLaneOffset = (int) Math.round(((trackCount - 1) / 2.0) * trackSpacing); + return SELECTION_PADDING + maxLaneOffset + 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(); + trackSpacing = railType.getTrackSpacing(); + + Integer trackCountFlag = getIntegerSetting(RailFlag.TRACK_COUNT); + Integer trackSpacingFlag = getIntegerSetting(RailFlag.TRACK_SPACING); + + if (trackCountFlag != null) + trackCount = trackCountFlag; + + if (trackSpacingFlag != null) + trackSpacing = trackSpacingFlag; + + 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; + } + + return true; } - 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) { @@ -429,10 +469,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/RailSettings.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java index ae244c3d..09629afa 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 @@ -11,6 +11,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 index 6c641532..5e5e9b39 100644 --- 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 @@ -2,31 +2,289 @@ import com.cryptomorin.xseries.XMaterial; import lombok.Getter; +import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import org.bukkit.Material; import org.jspecify.annotations.Nullable; -public enum RailType { +import java.util.List; - STANDARD("standard", "Standard", XMaterial.RAIL); +@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 MAX_IDENTIFIER_LENGTH = 32; + public static final int MAX_DISPLAY_NAME_LENGTH = 48; + + public static final int DEFAULT_TRACK_COUNT = 1; + public static final int DEFAULT_TRACK_SPACING = 5; + + private static final String IDENTIFIER_PATTERN = "[a-z0-9_-]+"; - @Getter private final String identifier; - @Getter private final String displayName; - @Getter private final XMaterial icon; + private final XMaterial railBlock; + private final List blocksBelow; + private final @Nullable XMaterial sleeperBlock; + private final int sleeperSpacing; + private final int trackCount; + private final int trackSpacing; + 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.railBlock = configuration.railBlock(); + this.blocksBelow = configuredBlocksBelow == null ? List.of() : List.copyOf(configuredBlocksBelow); + this.sleeperBlock = configuration.sleeperBlock(); + this.sleeperSpacing = configuration.sleeperSpacing(); + this.trackCount = configuration.trackCount(); + this.trackSpacing = configuration.trackSpacing(); + this.builtIn = builtIn; + } + + public boolean hasSleepers() { + return sleeperSpacing > 0 && sleeperBlock != null; + } + + /** + * 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), 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; + + return validateTracks(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 (!isPlaceableBlock(configuration.railBlock())) + 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 && !isPlaceableBlock(configuration.sleeperBlock())) + 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); + + 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 isPlaceableBlock(@Nullable XMaterial material) { + if (material == null) + return false; - RailType(String identifier, String displayName, XMaterial icon) { - this.identifier = identifier; - this.displayName = displayName; - this.icon = icon; + Material bukkitMaterial = material.get(); + return bukkitMaterial != null && bukkitMaterial.isBlock(); } public static @Nullable RailType byString(String value) { - for (RailType railType : RailType.values()) { - if (railType.getIdentifier().equalsIgnoreCase(value) || railType.name().equalsIgnoreCase(value)) - return railType; + 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 @Nullable XMaterial railBlock; + private @Nullable List blocksBelow; + private @Nullable XMaterial sleeperBlock; + private int sleeperSpacing; + private int trackCount; + private int trackSpacing; + + public @Nullable String identifier() { + return identifier; } - return null; + 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 @Nullable XMaterial railBlock() { + return railBlock; + } + + public Configuration railBlock(@Nullable XMaterial railBlock) { + this.railBlock = railBlock; + return this; + } + + public @Nullable List blocksBelow() { + return blocksBelow; + } + + public Configuration blocksBelow(@Nullable List blocksBelow) { + this.blocksBelow = blocksBelow; + return this; + } + + public @Nullable XMaterial sleeperBlock() { + return sleeperBlock; + } + + public Configuration sleeperBlock(@Nullable XMaterial sleeperBlock) { + this.sleeperBlock = 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; + } } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTypeManager.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTypeManager.java new file mode 100644 index 00000000..402530c3 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTypeManager.java @@ -0,0 +1,249 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail; + +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.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 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 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 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; + + ConfigurationSection typesSection = YamlConfiguration.loadConfiguration(file).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; + } + + 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(new RailType.Configuration() + .identifier(railType.getIdentifier()) + .displayName(railType.getDisplayName()) + .icon(railType.getIcon()) + .railBlock(railType.getRailBlock()) + .blocksBelow(railType.getBlocksBelow()) + .sleeperBlock(railType.getSleeperBlock()) + .sleeperSpacing(railType.getSleeperSpacing()) + .trackCount(railType.getTrackCount()) + .trackSpacing(railType.getTrackSpacing())); + + 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); + return save(); + } + + /** + * 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()); + return save(); + } + + 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); + XMaterial railBlock = parseMaterial(section.getString(KEY_RAIL_BLOCK)); + List blocksBelow = parseMaterials(section.getStringList(KEY_BLOCKS_BELOW)); + XMaterial sleeperBlock = parseMaterial(section.getString(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); + + String error = RailType.validate(new RailType.Configuration() + .identifier(normalizedIdentifier) + .displayName(displayName) + .icon(railBlock) + .railBlock(railBlock) + .blocksBelow(blocksBelow) + .sleeperBlock(sleeperBlock) + .sleeperSpacing(sleeperSpacing) + .trackCount(trackCount) + .trackSpacing(trackSpacing)); + + 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 ? railBlock : icon) + .railBlock(railBlock) + .blocksBelow(blocksBelow) + .sleeperBlock(sleeperBlock) + .sleeperSpacing(sleeperSpacing) + .trackCount(trackCount) + .trackSpacing(trackSpacing), false)); + } + + private @Nullable String save() { + YamlConfiguration config = new YamlConfiguration(); + + 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.getRailBlock().name()); + config.set(path + "." + KEY_BLOCKS_BELOW, railType.getBlocksBelow().stream().map(XMaterial::name).toList()); + + XMaterial sleeperBlock = railType.getSleeperBlock(); + + if (sleeperBlock != null) + config.set(path + "." + KEY_SLEEPER_BLOCK, sleeperBlock.name()); + + config.set(path + "." + KEY_SLEEPER_SPACING, railType.getSleeperSpacing()); + config.set(path + "." + KEY_TRACK_COUNT, railType.getTrackCount()); + config.set(path + "." + KEY_TRACK_SPACING, railType.getTrackSpacing()); + } + + 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; + } +} From 34a3d33bdfab3067049169a5a289698db57e061e Mon Sep 17 00:00:00 2001 From: Jasupa Date: Tue, 7 Jul 2026 12:46:24 +0200 Subject: [PATCH 2/2] feat(rail): add rail type management menus --- .../rail/menu/RailBlockPickerMenu.java | 75 ++++ .../components/rail/menu/RailBlockRole.java | 89 +++++ .../components/rail/menu/RailTypeDraft.java | 43 +++ .../rail/menu/RailTypeEditorMenu.java | 283 ++++++++++++++ .../components/rail/menu/RailTypeMenu.java | 357 +++++++++++++++++- 5 files changed, 840 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java 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..f2d90ece --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java @@ -0,0 +1,75 @@ +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.utils.menus.BlockListMenu; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.List; + +/** + * Lets the player pick a single block for one of the {@link RailBlockRole} slots + * of a rail type draft, then returns to the editor menu. + */ +public class RailBlockPickerMenu extends BlockListMenu { + + private final RailTypeDraft draft; + private final RailBlockRole role; + + RailBlockPickerMenu(Player player, RailTypeDraft draft, RailBlockRole role, boolean autoLoad) { + super(player, role.getMenuTitle(), role.createChoices(), new RailTypeEditorMenu(player, draft, false), autoLoad); + + this.draft = draft; + this.role = role; + } + + @Override + protected void setPaginatedItemClickEventsAsync(List source) { + List itemStacks = source.stream().map(l -> (ItemStack) l).toList(); + int slot = 0; + + // Only one block can be picked per role, so selecting a block deselects the previous one. + 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.remove(type); + } else { + selectedMaterials.clear(); + selectedMaterials.add(type); + } + + reloadMenuAsync(); + }); + slot++; + } + } + + @Override + protected void setItemClickEventsAsync() { + super.setItemClickEventsAsync(); + + if (canProceed()) + getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + XMaterial material = Item.convertStringToXMaterial(selectedMaterials.getFirst()); + + if (material == null) + return; + + role.apply(draft, material); + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailTypeEditorMenu(clickPlayer, draft, true); + }); + } + + 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..349cd6c2 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java @@ -0,0 +1,89 @@ +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.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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() { + return createItems(List.of( + XMaterial.ANVIL, + XMaterial.CHIPPED_ANVIL, + XMaterial.DAMAGED_ANVIL, + XMaterial.RAIL, + XMaterial.POWERED_RAIL, + XMaterial.DETECTOR_RAIL, + XMaterial.ACTIVATOR_RAIL, + XMaterial.IRON_BARS, + XMaterial.SMOOTH_STONE_SLAB, + XMaterial.STONE_SLAB, + XMaterial.ANDESITE_WALL, + XMaterial.POLISHED_BLACKSTONE_WALL + )); + } + + @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.setBlockBelow(material); + } + }, + + SLEEPER_BLOCK("Choose a Sleeper Block") { + @Override + List createChoices() { + return MenuItems.getSolidBlocks(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setSleeperBlock(material); + } + }; + + @Getter + private final String menuTitle; + + RailBlockRole(String menuTitle) { + this.menuTitle = menuTitle; + } + + abstract List createChoices(); + + abstract void apply(RailTypeDraft draft, XMaterial material); + + private static List createItems(List materials) { + List items = new ArrayList<>(); + + for (XMaterial material : materials) { + ItemStack item = material.parseItem(); + + if (item != null) + items.add(item); + } + + return items; + } +} 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..86b93a43 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java @@ -0,0 +1,43 @@ +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.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 railBlock = XMaterial.ANVIL; + private XMaterial blockBelow = XMaterial.GRAVEL; + private XMaterial sleeperBlock = XMaterial.SPRUCE_PLANKS; + private int sleeperSpacing = RailType.MIN_SLEEPER_SPACING; + private int trackCount = RailType.DEFAULT_TRACK_COUNT; + private int trackSpacing = RailType.DEFAULT_TRACK_SPACING; + + 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.railBlock = railType.getRailBlock(); + draft.blockBelow = blocksBelow.isEmpty() ? XMaterial.GRAVEL : blocksBelow.getFirst(); + draft.sleeperBlock = railType.getSleeperBlock() == null ? XMaterial.SPRUCE_PLANKS : railType.getSleeperBlock(); + draft.sleeperSpacing = railType.getSleeperSpacing(); + draft.trackCount = railType.getTrackCount(); + draft.trackSpacing = railType.getTrackSpacing(); + return draft; + } +} 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..b4f139df --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java @@ -0,0 +1,283 @@ +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.RailTypeManager; +import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +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 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 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()), + "§eRail Type Name: §f" + getDisplayName(), + List.of("§7Click to change the display name.") + )); + + createCounter(HeadColor.WHITE, TRACK_COUNT_SLOT, "Track Count", draft.getTrackCount(), + RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT, "Tracks"); + createCounter(HeadColor.LIGHT_GRAY, TRACK_SPACING_SLOT, "Track Spacing", draft.getTrackSpacing(), + RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING, "Blocks"); + 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.getRailBlock(), + "§7The block the rails are made of." + )); + getMenu().getSlot(BLOCK_BELOW_SLOT).setItem(createBlockItem( + "Block Below the Rails", + draft.getBlockBelow(), + "§7The block placed between and below the rails." + )); + getMenu().getSlot(SLEEPER_BLOCK_SLOT).setItem(createBlockItem( + "Sleeper Block", + draft.getSleeperBlock(), + "§7Sleeper Spacing 0 disables sleepers." + )); + + setBackItem(BACK_ITEM_SLOT, new RailTypeMenu(getMenuPlayer(), false)); + getMenu().getSlot(SAVE_ITEM_SLOT).setItem(HeadFactory.head( + HeadTexture.CHECKMARK, + draft.getIdentifier() == null ? "§aSave Rail Type" : "§aUpdate 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); + 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); + + 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()), "§eChange Rail Type Name")) + .title("§8Change rail type name") + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private void saveRailType(Player clickPlayer) { + 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 + ? "Custom Rail " + identifier.substring("custom-".length()) + : draft.getDisplayName(); + + RailType railType = RailType.createCustom(new RailType.Configuration() + .identifier(identifier) + .displayName(displayName) + .icon(draft.getRailBlock()) + .railBlock(draft.getRailBlock()) + .blocksBelow(List.of(draft.getBlockBelow())) + .sleeperBlock(draft.getSleeperBlock()) + .sleeperSpacing(draft.getSleeperSpacing()) + .trackCount(draft.getTrackCount()) + .trackSpacing(draft.getTrackSpacing())); + + 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, XMaterial material, String description) { + Material bukkitMaterial = material.get(); + + if (bukkitMaterial == null) + bukkitMaterial = Objects.requireNonNull(XMaterial.BARRIER.get()); + + return Item.create( + bukkitMaterial, + "§e" + name + ": §f" + RailTypeMenu.formatMaterial(material), + List.of(description, "§7Click 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); + } +} 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..de20f67a 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,48 +1,315 @@ 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.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.menu.GeneratorMenu; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; import net.buildtheearth.buildteamtools.utils.menus.NameListMenu; import org.apache.commons.lang3.tuple.MutablePair; +import org.bukkit.Material; import org.bukkit.Sound; +import org.bukkit.enchantments.Enchantment; 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.List; +import java.util.Locale; import java.util.Objects; 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 CREATE_ITEM_SLOT = 29; + private static final int RELOAD_ITEM_SLOT = 33; + public RailTypeMenu(Player player, boolean autoLoad) { super(player, RAIL_TYPE_INV_NAME, getRailTypes(), new GeneratorMenu(player, false), autoLoad); + 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()); } private static @NonNull List> getRailTypes() { 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; + + for (RailType railType : rail.getRailTypeManager().getRailTypes()) + railTypes.add(new MutablePair<>(createRailTypeItem(railType), railType.getIdentifier())); return railTypes; } + private static ItemStack createRailTypeItem(RailType railType) { + Material icon = railType.getIcon().get(); + + if (icon == null) + icon = Objects.requireNonNull(XMaterial.RAIL.get()); + + List lore = new ArrayList<>(); + lore.add("§7Rail Block: §f" + formatMaterial(railType.getRailBlock())); + lore.add("§7Blocks Below: §f" + formatMaterials(railType.getBlocksBelow())); + + if (railType.hasSleepers()) + lore.add("§7Sleepers: §f" + formatMaterial(railType.getSleeperBlock()) + " §7every §f" + railType.getSleeperSpacing() + " §7blocks"); + else + lore.add("§7Sleepers: §fNone"); + + if (railType.getTrackCount() > 1) + lore.add("§7Tracks: §f" + railType.getTrackCount() + " §7with spacing §f" + railType.getTrackSpacing()); + else + lore.add("§7Tracks: §f1"); + + if (railType.isBuiltIn()) + lore.add("§8Right-Click to create an editable copy"); + else + lore.add("§8Right-Click to edit §7- §8Shift+Right-Click to delete"); + + return Item.create(icon, "§e" + railType.getDisplayName(), lore); + } + + static String formatMaterials(List materials) { + List names = new ArrayList<>(); + + for (XMaterial material : materials) + names.add(formatMaterial(material)); + + return String.join("§7, §f", names); + } + + static String formatMaterial(@Nullable XMaterial material) { + if (material == null) + return "None"; + + String[] words = material.name().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); + } + + @Override + protected void setPreviewItems() { + super.setPreviewItems(); + + setRailTypePageItems(); + + getMenu().getSlot(CREATE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NETHER_STAR.get()), + "§aCreate a Rail Type", + List.of("§7Configure and save a custom rail type.") + )); + + getMenu().getSlot(RELOAD_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.CLOCK.get()), + "§eReload Rail Types", + List.of("§7Re-reads rail-types.yml from disk,", "§7so you can test changes without a restart.") + )); + } + + @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) { + ItemStack displayedItem = item.getLeft(); + + if (selectedNames.contains(item.getRight())) + displayedItem = new Item(displayedItem.clone()) + .setAmount(1) + .addEnchantment(Enchantment.LUCK_OF_THE_SEA, 1) + .hideEnchantments(true) + .build(); + + 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(); + + if (clickInformation.getClickType() == ClickType.SHIFT_RIGHT) { + deleteCustomRailType(clickPlayer, type); + return; + } + + if (clickInformation.getClickType() == ClickType.RIGHT) { + openEditorForRailType(clickPlayer, type); + return; + } + + if (selectedNames.contains(type)) { + selectedNames.remove(type); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Deselected rail type '%s'.", item.getRight())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + } else { + selectedNames.clear(); + selectedNames.add(type); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Selected rail type '%s'.", item.getRight())); + 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 void openEditorForRailType(Player clickPlayer, String identifier) { + 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; + } + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + railType.isBuiltIn() + ? "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, !railType.isBuiltIn()), true); + } + + private void deleteCustomRailType(Player clickPlayer, String identifier) { + 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); + } + @Override protected void setItemClickEventsAsync() { super.setItemClickEventsAsync(); + setRailTypePageClickEvents(); + + getMenu().getSlot(CREATE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + 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) -> { + 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, true); + }); + if (canProceed()) getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { Settings settings = GeneratorModule.getInstance().getRail().getPlayerSettings().get(clickPlayer.getUniqueId()); @@ -53,9 +320,85 @@ protected void setItemClickEventsAsync() { railSettings.setValue(RailFlag.RAIL_TYPE, selectedNames.getFirst()); clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); GeneratorModule.getInstance().getRail().generate(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()), + "§eCurrent Page §7- §f" + getPage(), + List.of("§7Use 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()), + "§7No Previous Page", + List.of("§8You are already on the first page.") + ); + + return HeadFactory.head( + HeadTexture.WHITE_ARROW_LEFT, + "§ePrevious Page §7- §f" + (getPage() - 1), + new ArrayList<>(List.of("§7Click to show earlier rail types.")) + ); + } + + private ItemStack createNextPageItem() { + if (!hasNextPage()) + return Item.create( + Objects.requireNonNull(XMaterial.GRAY_STAINED_GLASS_PANE.get()), + "§7No Next Page", + List.of("§8There are no more rail types.") + ); + + return HeadFactory.head( + HeadTexture.WHITE_ARROW_RIGHT, + "§eNext Page §7- §f" + (getPage() + 1), + new ArrayList<>(List.of("§7Click 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); + } }