diff --git a/src/main/java/com/easyclaims/EasyClaims.java b/src/main/java/com/easyclaims/EasyClaims.java index fafec92..24e2b87 100644 --- a/src/main/java/com/easyclaims/EasyClaims.java +++ b/src/main/java/com/easyclaims/EasyClaims.java @@ -191,9 +191,41 @@ public void refreshWorldMap(String worldName) { } } + /** + * Toggles claim overlay visibility on the world map. + * Controls rendering via config flag - does NOT swap providers to preserve BetterMap settings. + * + * @param show true to show claims, false to hide + */ + public void setClaimMapVisibility(boolean show) { + config.setShowClaimsOnMap(show); + + // Refresh all world maps to apply the visibility change + // The EasyClaimsWorldMapProvider checks showClaimsOnMap at render time + for (Map.Entry entry : WORLDS.entrySet()) { + String worldName = entry.getKey(); + World world = entry.getValue(); + + if (world == null || world.getWorldConfig().isDeleteOnRemove()) { + continue; + } + + try { + refreshWorldMap(worldName); + getLogger().atInfo().log("[Map] Refreshed map for world: %s (claims %s)", + worldName, show ? "visible" : "hidden"); + } catch (Exception e) { + getLogger().atSevere().withCause(e).log("[Map] Error refreshing map for world: %s", worldName); + } + } + } + /** * Refreshes specific chunks on the world map. * More efficient than refreshing the entire map when only a few chunks changed. + * + * When text grouping is enabled, uses a larger refresh radius to ensure + * all chunks in potential groups are updated when claims change. * * @param worldName The world name * @param chunkX The chunk X coordinate @@ -206,10 +238,17 @@ public void refreshWorldMapChunk(String worldName, int chunkX, int chunkZ) { } try { - // Create a set with this chunk and its neighbors (for border updates) + // Determine refresh radius based on grouping settings + // When grouping is enabled, we need to refresh all chunks that could be + // in the same group, since adding/removing a chunk affects group membership. + // The grouping algorithm uses maxExpand=8, so use that radius. + // When grouping is disabled, only need to refresh immediate neighbors for borders. + int refreshRadius = config.isMapTextGrouping() ? 8 : 1; + + // Create a set with this chunk and surrounding chunks LongSet chunksToRefresh = new LongOpenHashSet(); - for (int dx = -1; dx <= 1; dx++) { - for (int dz = -1; dz <= 1; dz++) { + for (int dx = -refreshRadius; dx <= refreshRadius; dx++) { + for (int dz = -refreshRadius; dz <= refreshRadius; dz++) { chunksToRefresh.add(ChunkUtil.indexChunk(chunkX + dx, chunkZ + dz)); } } @@ -226,7 +265,8 @@ public void refreshWorldMapChunk(String worldName, int chunkX, int chunkZ) { } } - getLogger().atFine().log("[Map] Refreshed chunk %d,%d in world %s", chunkX, chunkZ, worldName); + getLogger().atFine().log("[Map] Refreshed chunk %d,%d (radius %d) in world %s", + chunkX, chunkZ, refreshRadius, worldName); } catch (Exception e) { getLogger().atWarning().withCause(e).log("[Map] Error refreshing chunk %d,%d", chunkX, chunkZ); } diff --git a/src/main/java/com/easyclaims/EasyClaimsAccess.java b/src/main/java/com/easyclaims/EasyClaimsAccess.java index 60673d3..28dcee5 100644 --- a/src/main/java/com/easyclaims/EasyClaimsAccess.java +++ b/src/main/java/com/easyclaims/EasyClaimsAccess.java @@ -139,4 +139,188 @@ public static String getClaimDisplayName(String worldName, int chunkX, int chunk // Fall back to owner name return getOwnerName(worldName, chunkX, chunkZ); } + + /** + * Checks if claim overlays should be shown on the map. + * This is a global setting that affects all players. + */ + public static boolean shouldShowClaimsOnMap() { + if (pluginConfig == null) return true; // Default to showing claims + return pluginConfig.isShowClaimsOnMap(); + } + + /** + * Gets the effective text scale for map rendering. + * @param imageWidth The width of the map image + * @param imageHeight The height of the map image + * @return The scale factor (0 = no text, 1-3 = text size) + */ + public static int getMapTextScale(int imageWidth, int imageHeight) { + if (pluginConfig == null) { + // Default AUTO behavior - match PluginConfig.MapTextScale.AUTO + // Keep text small - scale 1 is 7px tall, good for most tile sizes + int minDimension = Math.min(imageWidth, imageHeight); + if (minDimension < 12) return 0; // Too small for any text + if (minDimension < 128) return 1; // Scale 1 (7px) for 12-127px tiles + if (minDimension < 384) return 2; // Scale 2 (14px) for 128-383px tiles + return 3; // Scale 3 (21px) for 384px+ tiles + } + return pluginConfig.getMapTextScale().getEffectiveScale(imageWidth, imageHeight); + } + + /** + * Gets the effective text mode for map rendering. + * @param imageWidth The width of the map image + * @param imageHeight The height of the map image + * @return The text mode (never AUTO - resolved to actual mode) + */ + public static PluginConfig.MapTextMode getMapTextMode(int imageWidth, int imageHeight) { + if (pluginConfig == null) { + // Default AUTO behavior + int minDimension = Math.min(imageWidth, imageHeight); + return minDimension >= 64 ? PluginConfig.MapTextMode.OVERFLOW : PluginConfig.MapTextMode.FIT; + } + return pluginConfig.getMapTextMode().getEffectiveMode(imageWidth, imageHeight); + } + + /** + * Whether text grouping (spanning across tiles) is enabled. + * When disabled, each tile renders text independently. + */ + public static boolean isMapTextGroupingEnabled() { + if (pluginConfig == null) return false; // Default off + return pluginConfig.isMapTextGrouping(); + } + + /** + * Gets the claim group info for text spanning. + * Finds the maximal RECTANGLE of same-owner claims that contains this chunk. + * + * Algorithm: Iteratively expand outward from current chunk until stable. + * Then verify all chunks in the rectangle compute the SAME rectangle. + * This prevents issues with L-shaped or irregular claims where different + * chunks would compute overlapping but different rectangles. + * + * @param worldName The world name + * @param chunkX The chunk X coordinate + * @param chunkZ The chunk Z coordinate + * @return int[4] = {groupWidth, groupHeight, posX, posZ} or null if not claimed + * posX/posZ are 0-indexed position of this chunk within the rectangle + */ + public static int[] getClaimGroupInfo(String worldName, int chunkX, int chunkZ) { + UUID owner = getClaimOwner(worldName, chunkX, chunkZ); + if (owner == null) return null; + + int maxExpand = 8; // Allow larger groups + + // Compute the rectangle from this chunk's perspective + int[] rect = computeRectangle(worldName, chunkX, chunkZ, owner, maxExpand); + int minX = rect[0], maxX = rect[1], minZ = rect[2], maxZ = rect[3]; + + int groupWidth = maxX - minX + 1; + int groupHeight = maxZ - minZ + 1; + + // For single chunks, no verification needed + if (groupWidth == 1 && groupHeight == 1) { + return new int[]{1, 1, 0, 0}; + } + + // Verify all chunks in the rectangle compute the SAME rectangle + // This prevents issues with L-shaped or irregular claims + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + int[] otherRect = computeRectangle(worldName, x, z, owner, maxExpand); + if (otherRect[0] != minX || otherRect[1] != maxX || + otherRect[2] != minZ || otherRect[3] != maxZ) { + // Inconsistent rectangles - this chunk is at a junction + // Fall back to single-chunk rendering + return new int[]{1, 1, 0, 0}; + } + } + } + + int posX = chunkX - minX; + int posZ = chunkZ - minZ; + + return new int[]{groupWidth, groupHeight, posX, posZ}; + } + + /** + * Computes the maximal rectangle containing (chunkX, chunkZ) where all chunks + * are owned by the given owner. + * + * @return int[4] = {minX, maxX, minZ, maxZ} + */ + private static int[] computeRectangle(String worldName, int chunkX, int chunkZ, UUID owner, int maxExpand) { + int minX = chunkX, maxX = chunkX; + int minZ = chunkZ, maxZ = chunkZ; + + // Iteratively expand until no changes + boolean changed = true; + int iterations = 0; + int maxIterations = maxExpand * 4; // Safety limit + + while (changed && iterations < maxIterations) { + changed = false; + iterations++; + + // Try expand left - check if full column is owned + if (minX > chunkX - maxExpand) { + if (isColumnOwnedBy(worldName, minX - 1, minZ, maxZ, owner)) { + minX--; + changed = true; + } + } + + // Try expand right + if (maxX < chunkX + maxExpand) { + if (isColumnOwnedBy(worldName, maxX + 1, minZ, maxZ, owner)) { + maxX++; + changed = true; + } + } + + // Try expand up + if (minZ > chunkZ - maxExpand) { + if (isRowOwnedBy(worldName, minX, maxX, minZ - 1, owner)) { + minZ--; + changed = true; + } + } + + // Try expand down + if (maxZ < chunkZ + maxExpand) { + if (isRowOwnedBy(worldName, minX, maxX, maxZ + 1, owner)) { + maxZ++; + changed = true; + } + } + } + + return new int[]{minX, maxX, minZ, maxZ}; + } + + /** + * Checks if an entire column (fixed X, range of Z) is owned by the given owner. + */ + private static boolean isColumnOwnedBy(String worldName, int x, int minZ, int maxZ, UUID owner) { + for (int z = minZ; z <= maxZ; z++) { + if (!owner.equals(getClaimOwner(worldName, x, z))) { + return false; + } + } + return true; + } + + /** + * Checks if an entire row (range of X, fixed Z) is owned by the given owner. + */ + private static boolean isRowOwnedBy(String worldName, int minX, int maxX, int z, UUID owner) { + for (int x = minX; x <= maxX; x++) { + if (!owner.equals(getClaimOwner(worldName, x, z))) { + return false; + } + } + return true; + } } diff --git a/src/main/java/com/easyclaims/commands/subcommands/admin/AdminSetSubcommand.java b/src/main/java/com/easyclaims/commands/subcommands/admin/AdminSetSubcommand.java index d8857aa..ca6f767 100644 --- a/src/main/java/com/easyclaims/commands/subcommands/admin/AdminSetSubcommand.java +++ b/src/main/java/com/easyclaims/commands/subcommands/admin/AdminSetSubcommand.java @@ -27,5 +27,9 @@ public AdminSetSubcommand(EasyClaims plugin) { addSubCommand(new SetMaxSubcommand(plugin)); addSubCommand(new SetBufferSubcommand(plugin)); addSubCommand(new SetAllowPvpToggleSubcommand(plugin)); + addSubCommand(new SetShowClaimsOnMapSubcommand(plugin)); + addSubCommand(new SetMapTextScaleSubcommand(plugin)); + addSubCommand(new SetMapTextModeSubcommand(plugin)); + addSubCommand(new SetMapTextGroupingSubcommand(plugin)); } } diff --git a/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextGroupingSubcommand.java b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextGroupingSubcommand.java new file mode 100644 index 0000000..dfec81f --- /dev/null +++ b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextGroupingSubcommand.java @@ -0,0 +1,85 @@ +package com.easyclaims.commands.subcommands.admin.set; + +import java.awt.Color; + +import javax.annotation.Nonnull; + +import com.easyclaims.EasyClaims; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +/** + * Admin command to toggle text grouping on the world map. + * When enabled, text spans across multiple adjacent claim tiles. + * When disabled (default), each tile renders text independently. + */ +public class SetMapTextGroupingSubcommand extends AbstractPlayerCommand { + private final EasyClaims plugin; + private final RequiredArg valueArg; + + public SetMapTextGroupingSubcommand(EasyClaims plugin) { + super("maptextgrouping", "Toggle text spanning across claim tiles (experimental)"); + this.plugin = plugin; + this.valueArg = withRequiredArg("enabled", "1/0, true/false, on/off", ArgTypes.STRING); + addAliases("textgrouping", "grouptext"); + requirePermission("easyclaims.admin"); + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref playerRef, + @Nonnull PlayerRef playerData, + @Nonnull World world) { + String input = valueArg.get(ctx).toLowerCase().trim(); + + // Parse various boolean representations + Boolean value = parseBoolean(input); + if (value == null) { + playerData.sendMessage(Message.raw("Invalid value: " + input + ". Use: 1/0, true/false, on/off").color(Color.RED)); + return; + } + + // Check if value is already set + boolean currentValue = plugin.getPluginConfig().isMapTextGrouping(); + if (value == currentValue) { + playerData.sendMessage(Message.raw("Text grouping is already " + (value ? "enabled" : "disabled") + ".").color(Color.YELLOW)); + return; + } + + plugin.getPluginConfig().setMapTextGrouping(value); + + if (value) { + playerData.sendMessage(Message.raw("Text grouping enabled (experimental). Text will span across adjacent claim tiles.").color(new Color(85, 255, 85))); + } else { + playerData.sendMessage(Message.raw("Text grouping disabled. Each tile renders text independently.").color(new Color(85, 255, 85))); + } + + playerData.sendMessage(Message.raw("Refreshing map...").color(Color.GRAY)); + + // Refresh all world maps to apply the change + for (String worldName : EasyClaims.WORLDS.keySet()) { + plugin.refreshWorldMap(worldName); + } + } + + /** + * Parses various boolean representations. + * @return true, false, or null if invalid + */ + private Boolean parseBoolean(String input) { + return switch (input) { + case "1", "true", "on", "yes", "enable", "enabled" -> true; + case "0", "false", "off", "no", "disable", "disabled" -> false; + default -> null; + }; + } +} diff --git a/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextModeSubcommand.java b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextModeSubcommand.java new file mode 100644 index 0000000..c5b7f0a --- /dev/null +++ b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextModeSubcommand.java @@ -0,0 +1,73 @@ +package com.easyclaims.commands.subcommands.admin.set; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.easyclaims.EasyClaims; +import com.easyclaims.config.PluginConfig.MapTextMode; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * Admin command to set the map text rendering mode for claim overlays. + * Controls how owner names are rendered on claim tiles. + */ +public class SetMapTextModeSubcommand extends AbstractPlayerCommand { + private final EasyClaims plugin; + private final RequiredArg valueArg; + + public SetMapTextModeSubcommand(EasyClaims plugin) { + super("maptextmode", "Set text rendering mode for claim overlays"); + this.plugin = plugin; + this.valueArg = withRequiredArg("mode", "off/overflow/fit/auto", ArgTypes.STRING); + addAliases("textmode"); + requirePermission("easyclaims.admin"); + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref playerRef, + @Nonnull PlayerRef playerData, + @Nonnull World world) { + String input = valueArg.get(ctx).toLowerCase().trim(); + + MapTextMode mode = MapTextMode.fromString(input); + + // Check if it's a valid input + if (mode == null) { + String options = Arrays.stream(MapTextMode.values()) + .map(m -> m.name().toLowerCase()) + .collect(Collectors.joining(", ")); + playerData.sendMessage(Message.raw("Invalid mode: " + input + ". Options: " + options).color(Color.RED)); + return; + } + + // Check if already set + MapTextMode current = plugin.getPluginConfig().getMapTextMode(); + if (mode == current) { + playerData.sendMessage(Message.raw("Map text mode is already set to " + mode.name().toLowerCase() + ".").color(Color.YELLOW)); + return; + } + + plugin.getPluginConfig().setMapTextMode(mode); + playerData.sendMessage(Message.raw("Map text mode set to: " + mode.name().toLowerCase()).color(Color.GREEN)); + playerData.sendMessage(Message.raw(" " + mode.description).color(Color.GRAY)); + playerData.sendMessage(Message.raw("Refreshing map...").color(Color.GRAY)); + + // Refresh all world maps to apply the change + for (String worldName : EasyClaims.WORLDS.keySet()) { + plugin.refreshWorldMap(worldName); + } + } +} diff --git a/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextScaleSubcommand.java b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextScaleSubcommand.java new file mode 100644 index 0000000..b69fb77 --- /dev/null +++ b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetMapTextScaleSubcommand.java @@ -0,0 +1,72 @@ +package com.easyclaims.commands.subcommands.admin.set; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.easyclaims.EasyClaims; +import com.easyclaims.config.PluginConfig.MapTextScale; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * Admin command to set the map text scale for claim overlays. + * Controls the size of owner/trusted player names on the map. + */ +public class SetMapTextScaleSubcommand extends AbstractPlayerCommand { + private final EasyClaims plugin; + private final RequiredArg valueArg; + + public SetMapTextScaleSubcommand(EasyClaims plugin) { + super("maptextscale", "Set text size for claim owner names on map"); + this.plugin = plugin; + this.valueArg = withRequiredArg("scale", "off/small/medium/large/auto", ArgTypes.STRING); + addAliases("textscale", "maptext"); + requirePermission("easyclaims.admin"); + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref playerRef, + @Nonnull PlayerRef playerData, + @Nonnull World world) { + String input = valueArg.get(ctx).toLowerCase().trim(); + + MapTextScale scale = MapTextScale.fromString(input); + + // Check if it's a valid input + if (scale == null) { + String options = Arrays.stream(MapTextScale.values()) + .map(s -> s.name().toLowerCase()) + .collect(Collectors.joining(", ")); + playerData.sendMessage(Message.raw("Invalid scale: " + input + ". Options: " + options).color(Color.RED)); + return; + } + + // Check if already set + MapTextScale current = plugin.getPluginConfig().getMapTextScale(); + if (scale == current) { + playerData.sendMessage(Message.raw("Map text scale is already set to " + scale.name().toLowerCase() + ".").color(Color.YELLOW)); + return; + } + + plugin.getPluginConfig().setMapTextScale(scale); + playerData.sendMessage(Message.raw("Map text scale set to: " + scale.name().toLowerCase() + " (" + scale.description + ")").color(Color.GREEN)); + playerData.sendMessage(Message.raw("Refreshing map...").color(Color.GRAY)); + + // Refresh all world maps to apply the change + for (String worldName : EasyClaims.WORLDS.keySet()) { + plugin.refreshWorldMap(worldName); + } + } +} diff --git a/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetShowClaimsOnMapSubcommand.java b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetShowClaimsOnMapSubcommand.java new file mode 100644 index 0000000..59e4eb1 --- /dev/null +++ b/src/main/java/com/easyclaims/commands/subcommands/admin/set/SetShowClaimsOnMapSubcommand.java @@ -0,0 +1,77 @@ +package com.easyclaims.commands.subcommands.admin.set; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.easyclaims.EasyClaims; +import com.easyclaims.util.Messages; + +import javax.annotation.Nonnull; +import java.awt.Color; + +/** + * Admin command to toggle whether claims are shown on the world map. + * When disabled, the map shows normal terrain without claim overlays. + * Accepts: true/false, 1/0, on/off, yes/no + */ +public class SetShowClaimsOnMapSubcommand extends AbstractPlayerCommand { + private final EasyClaims plugin; + private final RequiredArg valueArg; + + public SetShowClaimsOnMapSubcommand(EasyClaims plugin) { + super("showclaimsonmap", "Toggle claim overlays on the world map"); + this.plugin = plugin; + this.valueArg = withRequiredArg("visible", "1/0, true/false, on/off", ArgTypes.STRING); + addAliases("mapclaims", "claimmap"); + requirePermission("easyclaims.admin"); + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref playerRef, + @Nonnull PlayerRef playerData, + @Nonnull World world) { + String input = valueArg.get(ctx).toLowerCase().trim(); + + // Parse various boolean representations + Boolean value = parseBoolean(input); + if (value == null) { + playerData.sendMessage(Message.raw("Invalid value: " + input + ". Use: 1/0, true/false, on/off").color(Color.RED)); + return; + } + + // Check if value is already set + boolean currentValue = plugin.getPluginConfig().isShowClaimsOnMap(); + if (value == currentValue) { + playerData.sendMessage(Message.raw("Claim overlays are already " + (value ? "visible" : "hidden") + ".").color(Color.YELLOW)); + return; + } + + playerData.sendMessage(Messages.claimMapRefreshing()); + + // Toggle claim visibility - this handles provider switching and refresh + plugin.setClaimMapVisibility(value); + + playerData.sendMessage(Messages.claimMapVisibilityChanged(value)); + } + + /** + * Parses various boolean representations. + * @return true, false, or null if invalid + */ + private Boolean parseBoolean(String input) { + return switch (input) { + case "1", "true", "on", "yes", "enable", "enabled", "show" -> true; + case "0", "false", "off", "no", "disable", "disabled", "hide" -> false; + default -> null; + }; + } +} diff --git a/src/main/java/com/easyclaims/config/PluginConfig.java b/src/main/java/com/easyclaims/config/PluginConfig.java index 829a13a..f8e5116 100644 --- a/src/main/java/com/easyclaims/config/PluginConfig.java +++ b/src/main/java/com/easyclaims/config/PluginConfig.java @@ -135,6 +135,23 @@ public boolean isPvpInPlayerClaims() { return config.pvpInPlayerClaims; } + /** + * Whether to show claims on the world map. + * When enabled, claims are rendered as colored overlays on the map. + * When disabled, the map shows normal terrain without claim overlays. + */ + public boolean isShowClaimsOnMap() { + return config.showClaimsOnMap; + } + + /** + * Gets the map text scale setting. + * Controls the size of owner/trusted names on claim overlays. + */ + public MapTextScale getMapTextScale() { + return config.mapTextScale; + } + // ===== SETTERS (auto-save) ===== public void setClaimsPerHour(double value) { @@ -167,6 +184,16 @@ public void setPvpInPlayerClaims(boolean value) { save(); } + public void setShowClaimsOnMap(boolean value) { + config.showClaimsOnMap = value; + save(); + } + + public void setMapTextScale(MapTextScale value) { + config.mapTextScale = value; + save(); + } + // ===== LEGACY GETTERS (for compatibility) ===== /** @deprecated Use getClaimsPerHour() */ @@ -233,5 +260,140 @@ private static class ConfigData { int playtimeSaveInterval = 60; int claimBufferSize = 2; // Buffer zone in chunks around claims where others can't claim boolean pvpInPlayerClaims = true; // true = PvP server, false = PvE server + boolean showClaimsOnMap = true; // Whether to show claim overlays on the world map + MapTextScale mapTextScale = MapTextScale.AUTO; // Text scale for claim owner names on map + MapTextMode mapTextMode = MapTextMode.AUTO; // Text rendering mode for claim overlays + boolean mapTextGrouping = false; // Whether to span text across multiple claim tiles (experimental) + } + + /** + * Gets the map text mode setting. + * Controls how text is rendered on claim overlays. + */ + public MapTextMode getMapTextMode() { + return config.mapTextMode; + } + + public void setMapTextMode(MapTextMode value) { + config.mapTextMode = value; + save(); + } + + /** + * Whether text should span across multiple claim tiles (experimental). + * When disabled (default), each tile renders text independently. + * When enabled, adjacent same-owner tiles try to share text rendering. + */ + public boolean isMapTextGrouping() { + return config.mapTextGrouping; + } + + public void setMapTextGrouping(boolean value) { + config.mapTextGrouping = value; + save(); + } + + /** + * Enum for map text rendering mode. + * Controls how owner names are rendered on claim tiles. + */ + public enum MapTextMode { + OFF("No text displayed on claims"), + OVERFLOW("Text may extend beyond tile boundaries (vanilla-style)"), + FIT("Text scaled to fit within tile boundaries"), + AUTO("Auto-detect: FIT for small tiles, OVERFLOW for large tiles"); + + public final String description; + + MapTextMode(String description) { + this.description = description; + } + + /** + * Determines the effective mode based on tile dimensions. + * @param tileWidth Width of the map tile + * @param tileHeight Height of the map tile + * @return The actual mode to use (never AUTO) + */ + public MapTextMode getEffectiveMode(int tileWidth, int tileHeight) { + if (this != AUTO) { + return this; + } + // For small tiles (typically BetterMap), use FIT mode + // For large tiles (vanilla), use OVERFLOW mode + int minDimension = Math.min(tileWidth, tileHeight); + return minDimension >= 64 ? OVERFLOW : FIT; + } + + /** + * Parse from string (case-insensitive). + */ + public static MapTextMode fromString(String value) { + if (value == null) return null; + String normalized = value.trim(); + if (normalized.isEmpty()) return null; + try { + return valueOf(normalized.toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + } + + /** + * Enum for map text scale settings. + * Controls the size of owner/trusted player names on claim overlays. + */ + public enum MapTextScale { + OFF(0, "No text displayed"), + SMALL(1, "Small text (7px)"), + MEDIUM(2, "Medium text (14px)"), + LARGE(3, "Large text (21px)"), + AUTO(-1, "Auto-scale based on map quality"); + + public final int scale; + public final String description; + + MapTextScale(int scale, String description) { + this.scale = scale; + this.description = description; + } + + /** + * Get scale value, or calculate based on image dimensions for AUTO mode. + */ + public int getEffectiveScale(int imageWidth, int imageHeight) { + if (this == AUTO) { + // Auto-calculate based on image size + // Keep text small - scale 1 is 7px tall, good for most tile sizes + int minDimension = Math.min(imageWidth, imageHeight); + if (minDimension < 12) return 0; // Too small for any text + if (minDimension < 128) return 1; // Scale 1 (7px) for 12-127px tiles + if (minDimension < 384) return 2; // Scale 2 (14px) for 128-383px tiles + return 3; // Scale 3 (21px) for 384px+ tiles + } + return scale; + } + + /** + * Parse from string (case-insensitive). + */ + public static MapTextScale fromString(String value) { + if (value == null) return null; + String normalized = value.trim(); + if (normalized.isEmpty()) return null; + try { + return valueOf(normalized.toUpperCase()); + } catch (IllegalArgumentException e) { + // Try parsing as number + try { + int num = Integer.parseInt(normalized); + for (MapTextScale scale : values()) { + if (scale.scale == num) return scale; + } + } catch (NumberFormatException ignored) {} + return null; + } + } } } diff --git a/src/main/java/com/easyclaims/map/BitmapFont.java b/src/main/java/com/easyclaims/map/BitmapFont.java index 43d758b..0316f91 100644 --- a/src/main/java/com/easyclaims/map/BitmapFont.java +++ b/src/main/java/com/easyclaims/map/BitmapFont.java @@ -1,15 +1,31 @@ package com.easyclaims.map; /** - * 5x7 bitmap font for rendering text on map tiles. - * Each character is stored as an array of 7 rows, where each row is a 5-bit bitmask. + * Bitmap fonts for rendering text on map tiles. + * Includes standard 5x7 font and micro 3x5 font for compact rendering. + * Each character is stored as an array of rows, where each row is a bitmask. */ public class BitmapFont { + // Standard font: 5x7 pixels public static final int CHAR_WIDTH = 5; public static final int CHAR_HEIGHT = 7; public static final int CHAR_SPACING = 1; + // Micro font: 3x5 pixels (more compact) + public static final int MICRO_CHAR_WIDTH = 3; + public static final int MICRO_CHAR_HEIGHT = 5; + public static final int MICRO_CHAR_SPACING = 1; + + // Micro font data: 3x5 pixels per character + private static final int[][] MICRO_GLYPHS = new int[128][]; + + // Target text height as a fraction of image height (e.g., 0.15 = 15% of image height) + private static final float TARGET_TEXT_HEIGHT_RATIO = 0.18f; + // Minimum and maximum scale factors + private static final int MIN_SCALE = 1; + private static final int MAX_SCALE = 4; + // Font data: 5x7 pixels per character private static final int[][] GLYPHS = new int[128][]; @@ -70,6 +86,71 @@ public class BitmapFont { GLYPHS['\''] = new int[]{0b00100, 0b00100, 0b01000, 0b00000, 0b00000, 0b00000, 0b00000}; GLYPHS['!'] = new int[]{0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100}; GLYPHS['?'] = new int[]{0b01110, 0b10001, 0b00010, 0b00100, 0b00100, 0b00000, 0b00100}; + GLYPHS['['] = new int[]{0b01110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b01110}; + GLYPHS[']'] = new int[]{0b01110, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01110}; + GLYPHS['('] = new int[]{0b00010, 0b00100, 0b01000, 0b01000, 0b01000, 0b00100, 0b00010}; + GLYPHS[')'] = new int[]{0b01000, 0b00100, 0b00010, 0b00010, 0b00010, 0b00100, 0b01000}; + GLYPHS['/'] = new int[]{0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000}; + + // ===== MICRO FONT (3x5 pixels) ===== + // Letters A-Z + MICRO_GLYPHS['A'] = new int[]{0b010, 0b101, 0b111, 0b101, 0b101}; + MICRO_GLYPHS['B'] = new int[]{0b110, 0b101, 0b110, 0b101, 0b110}; + MICRO_GLYPHS['C'] = new int[]{0b011, 0b100, 0b100, 0b100, 0b011}; + MICRO_GLYPHS['D'] = new int[]{0b110, 0b101, 0b101, 0b101, 0b110}; + MICRO_GLYPHS['E'] = new int[]{0b111, 0b100, 0b110, 0b100, 0b111}; + MICRO_GLYPHS['F'] = new int[]{0b111, 0b100, 0b110, 0b100, 0b100}; + MICRO_GLYPHS['G'] = new int[]{0b011, 0b100, 0b101, 0b101, 0b011}; + MICRO_GLYPHS['H'] = new int[]{0b101, 0b101, 0b111, 0b101, 0b101}; + MICRO_GLYPHS['I'] = new int[]{0b111, 0b010, 0b010, 0b010, 0b111}; + MICRO_GLYPHS['J'] = new int[]{0b001, 0b001, 0b001, 0b101, 0b010}; + MICRO_GLYPHS['K'] = new int[]{0b101, 0b110, 0b100, 0b110, 0b101}; + MICRO_GLYPHS['L'] = new int[]{0b100, 0b100, 0b100, 0b100, 0b111}; + MICRO_GLYPHS['M'] = new int[]{0b101, 0b111, 0b101, 0b101, 0b101}; + MICRO_GLYPHS['N'] = new int[]{0b101, 0b111, 0b111, 0b101, 0b101}; + MICRO_GLYPHS['O'] = new int[]{0b010, 0b101, 0b101, 0b101, 0b010}; + MICRO_GLYPHS['P'] = new int[]{0b110, 0b101, 0b110, 0b100, 0b100}; + MICRO_GLYPHS['Q'] = new int[]{0b010, 0b101, 0b101, 0b110, 0b011}; + MICRO_GLYPHS['R'] = new int[]{0b110, 0b101, 0b110, 0b101, 0b101}; + MICRO_GLYPHS['S'] = new int[]{0b011, 0b100, 0b010, 0b001, 0b110}; + MICRO_GLYPHS['T'] = new int[]{0b111, 0b010, 0b010, 0b010, 0b010}; + MICRO_GLYPHS['U'] = new int[]{0b101, 0b101, 0b101, 0b101, 0b010}; + MICRO_GLYPHS['V'] = new int[]{0b101, 0b101, 0b101, 0b010, 0b010}; + MICRO_GLYPHS['W'] = new int[]{0b101, 0b101, 0b101, 0b111, 0b101}; + MICRO_GLYPHS['X'] = new int[]{0b101, 0b101, 0b010, 0b101, 0b101}; + MICRO_GLYPHS['Y'] = new int[]{0b101, 0b101, 0b010, 0b010, 0b010}; + MICRO_GLYPHS['Z'] = new int[]{0b111, 0b001, 0b010, 0b100, 0b111}; + + // Lowercase maps to uppercase + for (char c = 'a'; c <= 'z'; c++) { + MICRO_GLYPHS[c] = MICRO_GLYPHS[Character.toUpperCase(c)]; + } + + // Numbers 0-9 + MICRO_GLYPHS['0'] = new int[]{0b010, 0b101, 0b101, 0b101, 0b010}; + MICRO_GLYPHS['1'] = new int[]{0b010, 0b110, 0b010, 0b010, 0b111}; + MICRO_GLYPHS['2'] = new int[]{0b110, 0b001, 0b010, 0b100, 0b111}; + MICRO_GLYPHS['3'] = new int[]{0b110, 0b001, 0b010, 0b001, 0b110}; + MICRO_GLYPHS['4'] = new int[]{0b101, 0b101, 0b111, 0b001, 0b001}; + MICRO_GLYPHS['5'] = new int[]{0b111, 0b100, 0b110, 0b001, 0b110}; + MICRO_GLYPHS['6'] = new int[]{0b011, 0b100, 0b110, 0b101, 0b010}; + MICRO_GLYPHS['7'] = new int[]{0b111, 0b001, 0b010, 0b010, 0b010}; + MICRO_GLYPHS['8'] = new int[]{0b010, 0b101, 0b010, 0b101, 0b010}; + MICRO_GLYPHS['9'] = new int[]{0b010, 0b101, 0b011, 0b001, 0b110}; + + // Special characters + MICRO_GLYPHS[' '] = new int[]{0b000, 0b000, 0b000, 0b000, 0b000}; + MICRO_GLYPHS['.'] = new int[]{0b000, 0b000, 0b000, 0b000, 0b010}; + MICRO_GLYPHS['-'] = new int[]{0b000, 0b000, 0b111, 0b000, 0b000}; + MICRO_GLYPHS['_'] = new int[]{0b000, 0b000, 0b000, 0b000, 0b111}; + } + + /** + * Calculate the width in pixels needed to render a micro string. + */ + public static int getMicroTextWidth(String text) { + if (text == null || text.isEmpty()) return 0; + return text.length() * MICRO_CHAR_WIDTH + (text.length() - 1) * MICRO_CHAR_SPACING; } /** @@ -80,6 +161,116 @@ public static int getTextWidth(String text) { return text.length() * CHAR_WIDTH + (text.length() - 1) * CHAR_SPACING; } + /** + * Calculate the width in pixels needed to render a string at a given scale. + */ + public static int getTextWidth(String text, int scale) { + if (text == null || text.isEmpty()) return 0; + return text.length() * (CHAR_WIDTH * scale) + (text.length() - 1) * (CHAR_SPACING * scale); + } + + /** + * Calculate the optimal scale factor for text based on image dimensions. + * Returns 0 if the image is too small for readable text. + * + * @param imageWidth The width of the image + * @param imageHeight The height of the image + * @return The scale factor to use (0 = skip text, 1-4 for rendering) + */ + public static int calculateScale(int imageWidth, int imageHeight) { + // Use the smaller dimension to determine scale + int minDimension = Math.min(imageWidth, imageHeight); + + // Images too small for readable text - skip rendering + if (minDimension < 12) { + return 0; + } + + // Scale breakpoints - keep text small for typical tile sizes (16-48px) + // Scale 1 = 7px text height, fits well in small tiles + int scale; + if (minDimension < 128) { + scale = 1; + } else if (minDimension < 384) { + scale = 2; + } else { + scale = 3; + } + + // Clamp to valid range + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); + } + + /** + * Check if text rendering should be skipped for this image size. + */ + public static boolean shouldSkipText(int imageWidth, int imageHeight) { + return calculateScale(imageWidth, imageHeight) == 0; + } + + /** + * Get the scaled character height. + */ + public static int getScaledCharHeight(int scale) { + return CHAR_HEIGHT * scale; + } + + /** + * Get the scaled line height (character height + spacing). + */ + public static int getScaledLineHeight(int scale) { + return (CHAR_HEIGHT + 2) * scale; + } + + /** + * Splits text into multiple lines to fit within a given width. + * + * @param text The text to potentially split + * @param maxWidth Maximum width in pixels per line + * @param scale The scale factor being used + * @return Array of lines (may be single element if text fits) + */ + public static String[] splitToFit(String text, int maxWidth, int scale) { + if (text == null || text.isEmpty()) return new String[]{""}; + + int textWidth = getTextWidth(text, scale); + if (textWidth <= maxWidth) { + return new String[]{text}; + } + + // Calculate max characters per line + int charWidth = CHAR_WIDTH * scale + CHAR_SPACING * scale; + int maxCharsPerLine = Math.max(1, maxWidth / charWidth); + + // Split into lines + java.util.List lines = new java.util.ArrayList<>(); + int start = 0; + + while (start < text.length()) { + int end = Math.min(start + maxCharsPerLine, text.length()); + lines.add(text.substring(start, end)); + start = end; + } + + return lines.toArray(new String[0]); + } + + /** + * Calculates how many lines are needed to display text at the given width. + */ + public static int getLinesNeeded(String text, int maxWidth, int scale) { + if (text == null || text.isEmpty()) return 1; + + int textWidth = getTextWidth(text, scale); + if (textWidth <= maxWidth) { + return 1; + } + + int charWidth = CHAR_WIDTH * scale + CHAR_SPACING * scale; + int maxCharsPerLine = Math.max(1, maxWidth / charWidth); + return (int) Math.ceil((double) text.length() / maxCharsPerLine); + } + /** * Draw text onto an image data array. */ @@ -157,6 +348,99 @@ public static void drawTextCenteredWithShadow(int[] imageData, int imageWidth, i drawTextWithShadow(imageData, imageWidth, imageHeight, text, startX, centerY, textColor, shadowColor); } + // ==================== SCALED DRAWING METHODS ==================== + + /** + * Draw text at a given scale. + */ + public static void drawTextScaled(int[] imageData, int imageWidth, int imageHeight, + String text, int startX, int startY, int color, int scale) { + if (text == null || scale < 1) return; + + int x = startX; + int scaledCharWidth = CHAR_WIDTH * scale; + int scaledSpacing = CHAR_SPACING * scale; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + drawCharScaled(imageData, imageWidth, imageHeight, c, x, startY, color, scale); + x += scaledCharWidth + scaledSpacing; + } + } + + /** + * Draw text centered horizontally at a given scale. + */ + public static void drawTextCenteredScaled(int[] imageData, int imageWidth, int imageHeight, + String text, int centerY, int color, int scale) { + int textWidth = getTextWidth(text, scale); + int startX = (imageWidth - textWidth) / 2; + drawTextScaled(imageData, imageWidth, imageHeight, text, startX, centerY, color, scale); + } + + /** + * Draw text with outline at a given scale. + */ + public static void drawTextWithOutlineScaled(int[] imageData, int imageWidth, int imageHeight, + String text, int startX, int startY, + int textColor, int outlineColor, int scale) { + // Draw outline in all 8 directions (scaled offset) + int outlineOffset = Math.max(1, scale / 2); + for (int dx = -outlineOffset; dx <= outlineOffset; dx++) { + for (int dy = -outlineOffset; dy <= outlineOffset; dy++) { + if (dx != 0 || dy != 0) { + drawTextScaled(imageData, imageWidth, imageHeight, text, + startX + dx, startY + dy, outlineColor, scale); + } + } + } + // Draw main text on top + drawTextScaled(imageData, imageWidth, imageHeight, text, startX, startY, textColor, scale); + } + + /** + * Draw text centered with outline at a given scale. + */ + public static void drawTextCenteredWithOutlineScaled(int[] imageData, int imageWidth, int imageHeight, + String text, int centerY, + int textColor, int outlineColor, int scale) { + int textWidth = getTextWidth(text, scale); + int startX = (imageWidth - textWidth) / 2; + drawTextWithOutlineScaled(imageData, imageWidth, imageHeight, text, startX, centerY, + textColor, outlineColor, scale); + } + + /** + * Draw a single character at a given scale. + */ + private static void drawCharScaled(int[] imageData, int imageWidth, int imageHeight, + char c, int x, int y, int color, int scale) { + int[] glyph = (c < GLYPHS.length) ? GLYPHS[c] : null; + if (glyph == null) { + glyph = GLYPHS[' ']; // Default to space for unknown chars + } + + for (int row = 0; row < CHAR_HEIGHT; row++) { + int rowBits = glyph[row]; + for (int col = 0; col < CHAR_WIDTH; col++) { + // Check if this pixel should be drawn + boolean pixelOn = (rowBits & (1 << (CHAR_WIDTH - 1 - col))) != 0; + if (pixelOn) { + // Draw a scale x scale block for this pixel + for (int sy = 0; sy < scale; sy++) { + for (int sx = 0; sx < scale; sx++) { + int px = x + (col * scale) + sx; + int py = y + (row * scale) + sy; + if (px >= 0 && px < imageWidth && py >= 0 && py < imageHeight) { + imageData[py * imageWidth + px] = color; + } + } + } + } + } + } + } + /** * Draw a single character. */ @@ -199,4 +483,176 @@ public static int packColor(int r, int g, int b, int a) { public static final int YELLOW = packColor(255, 255, 100, 255); public static final int GREEN = packColor(100, 255, 100, 255); public static final int RED = packColor(255, 100, 100, 255); + + // ==================== MICRO FONT METHODS (3x5) ==================== + + /** + * Draw micro text onto an image data array. + */ + public static void drawMicroText(int[] imageData, int imageWidth, int imageHeight, + String text, int startX, int startY, int color) { + if (text == null) return; + + int x = startX; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + drawMicroChar(imageData, imageWidth, imageHeight, c, x, startY, color); + x += MICRO_CHAR_WIDTH + MICRO_CHAR_SPACING; + } + } + + /** + * Draw micro text centered horizontally within the image. + */ + public static void drawMicroTextCentered(int[] imageData, int imageWidth, int imageHeight, + String text, int centerY, int color) { + int textWidth = getMicroTextWidth(text); + int startX = (imageWidth - textWidth) / 2; + drawMicroText(imageData, imageWidth, imageHeight, text, startX, centerY, color); + } + + /** + * Draw micro text with outline for visibility. + */ + public static void drawMicroTextWithOutline(int[] imageData, int imageWidth, int imageHeight, + String text, int startX, int startY, + int textColor, int outlineColor) { + // Draw outline in all 8 directions + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx != 0 || dy != 0) { + drawMicroText(imageData, imageWidth, imageHeight, text, startX + dx, startY + dy, outlineColor); + } + } + } + // Draw main text on top + drawMicroText(imageData, imageWidth, imageHeight, text, startX, startY, textColor); + } + + /** + * Draw micro text centered with outline. + */ + public static void drawMicroTextCenteredWithOutline(int[] imageData, int imageWidth, int imageHeight, + String text, int centerY, + int textColor, int outlineColor) { + int textWidth = getMicroTextWidth(text); + int startX = (imageWidth - textWidth) / 2; + drawMicroTextWithOutline(imageData, imageWidth, imageHeight, text, startX, centerY, textColor, outlineColor); + } + + /** + * Draw a single micro character. + */ + private static void drawMicroChar(int[] imageData, int imageWidth, int imageHeight, + char c, int x, int y, int color) { + int[] glyph = (c < MICRO_GLYPHS.length) ? MICRO_GLYPHS[c] : null; + if (glyph == null) { + glyph = MICRO_GLYPHS[' ']; // Default to space for unknown chars + if (glyph == null) return; // Space not defined + } + + for (int row = 0; row < MICRO_CHAR_HEIGHT; row++) { + int rowBits = glyph[row]; + for (int col = 0; col < MICRO_CHAR_WIDTH; col++) { + // Check if this pixel should be drawn + boolean pixelOn = (rowBits & (1 << (MICRO_CHAR_WIDTH - 1 - col))) != 0; + if (pixelOn) { + int px = x + col; + int py = y + row; + if (px >= 0 && px < imageWidth && py >= 0 && py < imageHeight) { + imageData[py * imageWidth + px] = color; + } + } + } + } + } + + // ==================== BALANCED TEXT SPLITTING ==================== + + /** + * Split text into multiple lines with balanced (even) character distribution. + * For example "ABCDEF" with 2 lines becomes ["ABC", "DEF"] not ["ABCD", "EF"]. + * + * @param text The text to split + * @param numLines Number of lines to split into + * @return Array of lines with balanced lengths + */ + public static String[] splitBalanced(String text, int numLines) { + if (text == null || text.isEmpty() || numLines <= 0) { + return new String[]{""}; + } + if (numLines == 1 || text.length() <= numLines) { + return new String[]{text}; + } + + String[] lines = new String[numLines]; + int totalLength = text.length(); + int baseLength = totalLength / numLines; + int remainder = totalLength % numLines; + + int pos = 0; + for (int i = 0; i < numLines; i++) { + // Distribute remainder across first few lines + int lineLength = baseLength + (i < remainder ? 1 : 0); + int end = Math.min(pos + lineLength, totalLength); + lines[i] = text.substring(pos, end); + pos = end; + } + + return lines; + } + + /** + * Calculate optimal number of lines needed to fit text in given width. + * Returns the minimum lines needed while keeping lines balanced. + * + * @param text The text to measure + * @param maxWidth Maximum width in pixels + * @param charWidth Width of each character including spacing + * @return Number of lines needed (1 if fits on single line) + */ + public static int calculateLinesNeeded(String text, int maxWidth, int charWidth) { + if (text == null || text.isEmpty()) return 1; + + int textLength = text.length(); + int maxCharsPerLine = Math.max(1, maxWidth / charWidth); + + if (textLength <= maxCharsPerLine) { + return 1; + } + + return (int) Math.ceil((double) textLength / maxCharsPerLine); + } + + // ==================== GLYPH ACCESSORS ==================== + + /** + * Get the glyph data for a character (standard 5x7 font). + * Used for external rendering with clipping support. + * + * @param c The character to get glyph for + * @return int array of row bitmasks, or null if not defined + */ + public static int[] getGlyph(char c) { + if (c >= 0 && c < GLYPHS.length && GLYPHS[c] != null) { + return GLYPHS[c]; + } + // Return space glyph as fallback + return GLYPHS[' ']; + } + + /** + * Get the glyph data for a character (micro 3x5 font). + * Used for external rendering with clipping support. + * + * @param c The character to get glyph for + * @return int array of row bitmasks, or null if not defined + */ + public static int[] getMicroGlyph(char c) { + if (c >= 0 && c < MICRO_GLYPHS.length && MICRO_GLYPHS[c] != null) { + return MICRO_GLYPHS[c]; + } + // Return space glyph as fallback + return MICRO_GLYPHS[' ']; + } } diff --git a/src/main/java/com/easyclaims/map/ClaimImageBuilder.java b/src/main/java/com/easyclaims/map/ClaimImageBuilder.java index 3b5ce44..a59ef57 100644 --- a/src/main/java/com/easyclaims/map/ClaimImageBuilder.java +++ b/src/main/java/com/easyclaims/map/ClaimImageBuilder.java @@ -1,5 +1,15 @@ package com.easyclaims.map; +import java.awt.Color; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.easyclaims.EasyClaimsAccess; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.protocol.packets.worldmap.MapImage; @@ -11,15 +21,6 @@ import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; import com.hypixel.hytale.server.core.universe.world.chunk.section.FluidSection; import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; -import com.easyclaims.EasyClaimsAccess; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.awt.Color; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; /** * Builds map images with claim overlays rendered directly into the terrain. @@ -49,6 +50,7 @@ public class ClaimImageBuilder { @Nonnull private final int[] fluidSamples; private final MapColor outColor = new MapColor(); + private int textScale = 1; private final UUID[] nearbyOwners = new UUID[4]; // Reusable array for neighboring claim lookups @Nullable private WorldChunk worldChunk; @@ -284,12 +286,16 @@ private ClaimImageBuilder generateImageAsync() { // Get claim info for this chunk using the accessor String worldName = this.worldChunk.getWorld().getName(); - UUID claimOwner = EasyClaimsAccess.getClaimOwner(worldName, chunkX, chunkZ); + + // Check if claim overlays should be shown + boolean showClaims = EasyClaimsAccess.shouldShowClaimsOnMap(); + + UUID claimOwner = showClaims ? EasyClaimsAccess.getClaimOwner(worldName, chunkX, chunkZ) : null; Color claimColor = claimOwner != null ? ClaimColorGenerator.getPlayerColor(claimOwner) : null; // Check for admin claims and PvP status - boolean isAdminClaim = EasyClaimsAccess.isAdminClaim(worldName, chunkX, chunkZ); - boolean pvpDisabled = EasyClaimsAccess.isPvPDisabled(worldName, chunkX, chunkZ); + boolean isAdminClaim = showClaims && EasyClaimsAccess.isAdminClaim(worldName, chunkX, chunkZ); + boolean pvpDisabled = showClaims && EasyClaimsAccess.isPvPDisabled(worldName, chunkX, chunkZ); // Admin claims get a distinct light blue color if (isAdminClaim && claimOwner != null) { @@ -297,10 +303,12 @@ private ClaimImageBuilder generateImageAsync() { } // Get neighboring claim owners to determine borders (reuse array to reduce allocations) - nearbyOwners[0] = EasyClaimsAccess.getClaimOwner(worldName, chunkX, chunkZ + 1); // SOUTH - nearbyOwners[1] = EasyClaimsAccess.getClaimOwner(worldName, chunkX, chunkZ - 1); // NORTH - nearbyOwners[2] = EasyClaimsAccess.getClaimOwner(worldName, chunkX + 1, chunkZ); // EAST - nearbyOwners[3] = EasyClaimsAccess.getClaimOwner(worldName, chunkX - 1, chunkZ); // WEST + if (showClaims) { + nearbyOwners[0] = EasyClaimsAccess.getClaimOwner(worldName, chunkX, chunkZ + 1); // SOUTH + nearbyOwners[1] = EasyClaimsAccess.getClaimOwner(worldName, chunkX, chunkZ - 1); // NORTH + nearbyOwners[2] = EasyClaimsAccess.getClaimOwner(worldName, chunkX + 1, chunkZ); // EAST + nearbyOwners[3] = EasyClaimsAccess.getClaimOwner(worldName, chunkX - 1, chunkZ); // WEST + } // Generate the image for (int ix = 0; ix < this.image.width; ++ix) { @@ -316,27 +324,6 @@ private ClaimImageBuilder generateImageAsync() { getBlockColor(blockId, tint, this.outColor); - // Apply claim overlay if this chunk is claimed - if (claimColor != null) { - boolean isBorder = false; - int borderSize = 2; - - // Check if this pixel is on a border where the adjacent chunk has a different owner - if ((ix <= borderSize && !Objects.equals(claimOwner, nearbyOwners[3])) // WEST border - || (ix >= this.image.width - borderSize - 1 && !Objects.equals(claimOwner, nearbyOwners[2])) // EAST border - || (iz <= borderSize && !Objects.equals(claimOwner, nearbyOwners[1])) // NORTH border - || (iz >= this.image.height - borderSize - 1 && !Objects.equals(claimOwner, nearbyOwners[0]))) { // SOUTH border - isBorder = true; - } - - applyClaimColor(claimColor, this.outColor, isBorder); - - // Apply green tint for PvP-disabled zones (safe areas) - if (pvpDisabled) { - applyPvPSafeOverlay(this.outColor, isBorder); - } - } - // Apply lighting/shading short north = this.neighborHeightSamples[sampleZ * (this.sampleWidth + 2) + sampleX + 1]; short south = this.neighborHeightSamples[(sampleZ + 2) * (this.sampleWidth + 2) + sampleX + 1]; @@ -351,7 +338,7 @@ private ClaimImageBuilder generateImageAsync() { height, north, south, west, east, northWest, northEast, southWest, southEast); this.outColor.multiply(shade); - // Apply fluid tinting + // Apply fluid tinting (water/lava color) if (height < 320) { int fluidId = this.fluidSamples[sampleIndex]; if (fluidId != 0) { @@ -361,6 +348,27 @@ private ClaimImageBuilder generateImageAsync() { } } + // Apply claim overlay AFTER fluid tinting so it shows on water/ocean tiles + if (claimColor != null) { + boolean isBorder = false; + int borderSize = 2; + + // Check if this pixel is on a border where the adjacent chunk has a different owner + if ((ix <= borderSize && !Objects.equals(claimOwner, nearbyOwners[3])) // WEST border + || (ix >= this.image.width - borderSize - 1 && !Objects.equals(claimOwner, nearbyOwners[2])) // EAST border + || (iz <= borderSize && !Objects.equals(claimOwner, nearbyOwners[1])) // NORTH border + || (iz >= this.image.height - borderSize - 1 && !Objects.equals(claimOwner, nearbyOwners[0]))) { // SOUTH border + isBorder = true; + } + + applyClaimColor(claimColor, this.outColor, isBorder); + + // Apply green tint for PvP-disabled zones (safe areas) + if (pvpDisabled) { + applyPvPSafeOverlay(this.outColor, isBorder); + } + } + this.image.data[iz * this.image.width + ix] = this.outColor.pack(); } } @@ -374,12 +382,32 @@ private ClaimImageBuilder generateImageAsync() { } /** - * Draws owner name and trusted player names on the map tile. - * Text is centered and may extend beyond tile boundaries. + * Draws owner name on the map tile. + * For multi-chunk claims, renders text spanning the full claim area, + * with each tile rendering its portion of the total text. + * + * Supports multiple rendering modes: + * - OVERFLOW: Text may extend beyond tile (vanilla-style, for large tiles) + * - FIT: Text uses full claim space across multiple tiles + * - AUTO: Automatically choose based on tile size * * @param pvpDisabled If true, shows "[Safe]" indicator */ private void drawClaimText(String worldName, int chunkX, int chunkZ, boolean pvpDisabled) { + int effectiveScale = EasyClaimsAccess.getMapTextScale(this.image.width, this.image.height); + if (effectiveScale <= 0) { + return; + } + this.textScale = Math.max(1, effectiveScale); + + // Get text mode based on tile size + com.easyclaims.config.PluginConfig.MapTextMode textMode = + EasyClaimsAccess.getMapTextMode(this.image.width, this.image.height); + + if (textMode == com.easyclaims.config.PluginConfig.MapTextMode.OFF) { + return; + } + // Use display name for admin claims, otherwise owner name String displayName = EasyClaimsAccess.getClaimDisplayName(worldName, chunkX, chunkZ); List trustedNames = EasyClaimsAccess.getTrustedPlayerNames(worldName, chunkX, chunkZ); @@ -393,16 +421,30 @@ private void drawClaimText(String worldName, int chunkX, int chunkZ, boolean pvp displayName = displayName + " [Safe]"; } - // Calculate vertical positioning - int lineHeight = BitmapFont.CHAR_HEIGHT + 2; // 7 + 2 = 9 pixels per line - int totalLines = 1 + Math.min(trustedNames.size(), 2); // Owner + up to 2 trusted + if (textMode == com.easyclaims.config.PluginConfig.MapTextMode.OVERFLOW) { + // Original behavior: text may extend beyond tile + drawTextOverflowMode(displayName, trustedNames); + } else { + // FIT mode: use full claim space across multiple tiles + drawTextSpanningMode(worldName, chunkX, chunkZ, displayName); + } + } + + /** + * Draw text in OVERFLOW mode - may extend beyond tile boundaries. + * Used for large tiles (vanilla map style). + */ + private void drawTextOverflowMode(String displayName, List trustedNames) { + int scale = Math.max(1, this.textScale); + int lineHeight = BitmapFont.getScaledLineHeight(scale); + int totalLines = 1 + Math.min(trustedNames.size(), 2); int startY = (this.image.height - (totalLines * lineHeight)) / 2; - // Draw owner/display name (white text with black outline for crisp visibility) - BitmapFont.drawTextCenteredWithOutline( + // Draw owner/display name + BitmapFont.drawTextCenteredWithOutlineScaled( this.image.data, this.image.width, this.image.height, displayName, startY, - BitmapFont.WHITE, BitmapFont.BLACK + BitmapFont.WHITE, BitmapFont.BLACK, scale ); // Draw trusted players (yellow) @@ -411,10 +453,10 @@ private void drawClaimText(String worldName, int chunkX, int chunkZ, boolean pvp for (String trustedName : trustedNames) { if (trustedCount >= 2) break; - BitmapFont.drawTextCenteredWithOutline( + BitmapFont.drawTextCenteredWithOutlineScaled( this.image.data, this.image.width, this.image.height, trustedName, trustedY, - BitmapFont.YELLOW, BitmapFont.BLACK + BitmapFont.YELLOW, BitmapFont.BLACK, scale ); trustedY += lineHeight; @@ -422,6 +464,467 @@ private void drawClaimText(String worldName, int chunkX, int chunkZ, boolean pvp } } + /** + * Draw text in FIT mode. + * + * When grouping is disabled (default): Simple per-tile rendering. + * When grouping is enabled: Uses merged group rendering with clipping. + */ + private void drawTextSpanningMode(String worldName, int chunkX, int chunkZ, String displayName) { + int tileWidth = this.image.width; + int tileHeight = this.image.height; + + // Skip tiles too small for any text + if (tileWidth < 12 || tileHeight < 8) { + return; + } + + int margin = 2; + int availWidth = tileWidth - margin * 2; + int availHeight = tileHeight - margin * 2; + + // Check if grouping is enabled + if (!EasyClaimsAccess.isMapTextGroupingEnabled()) { + // Simple per-tile rendering (default, stable) + TextFitResult fit = calculateBestFit(displayName, availWidth, availHeight); + renderTextWithFit(displayName, fit, tileWidth, tileHeight, 0, 0); + return; + } + + // Grouping enabled - use merged group rendering + int[] groupInfo = EasyClaimsAccess.getClaimGroupInfo(worldName, chunkX, chunkZ); + + if (groupInfo == null) { + // No group found, render locally + TextFitResult singleTileFit = calculateBestFit(displayName, availWidth, availHeight); + renderTextWithFit(displayName, singleTileFit, tileWidth, tileHeight, 0, 0); + return; + } + + int groupWidth = groupInfo[0]; // Number of tiles horizontally + int groupHeight = groupInfo[1]; // Number of tiles vertically + int posX = groupInfo[2]; // This tile's X position in group (0-indexed) + int posZ = groupInfo[3]; // This tile's Z position in group (0-indexed) + + // For 1x1 groups, just render locally + if (groupWidth == 1 && groupHeight == 1) { + TextFitResult singleTileFit = calculateBestFit(displayName, availWidth, availHeight); + renderTextWithFit(displayName, singleTileFit, tileWidth, tileHeight, 0, 0); + return; + } + + // Multi-tile group - ALL tiles render with clipping + // Calculate merged area dimensions + int mergedWidth = groupWidth * tileWidth; + int mergedHeight = groupHeight * tileHeight; + int mergedAvailWidth = mergedWidth - margin * 2; + int mergedAvailHeight = mergedHeight - margin * 2; + + // Calculate best fit for the merged area + TextFitResult mergedFit = calculateBestFit(displayName, mergedAvailWidth, mergedAvailHeight); + + // Calculate this tile's offset within the merged area + int offsetX = posX * tileWidth; + int offsetZ = posZ * tileHeight; + renderMergedText(displayName, mergedFit, mergedWidth, mergedHeight, + offsetX, offsetZ, tileWidth, tileHeight); + } + + /** + * Result of calculating how well text fits in a given area. + */ + private static class TextFitResult { + boolean fitsWell; // True if text fits without truncation + boolean useMicro; // True to use micro font + boolean vertical; // True to use vertical orientation + int lines; // Number of lines needed + int score; // Fit quality score (higher = better) + + TextFitResult(boolean fitsWell, boolean useMicro, boolean vertical, int lines, int score) { + this.fitsWell = fitsWell; + this.useMicro = useMicro; + this.vertical = vertical; + this.lines = lines; + this.score = score; + } + } + + /** + * Calculate the best text fit for given dimensions. + * Tries both fonts and both orientations, returns the best option. + */ + private TextFitResult calculateBestFit(String text, int availWidth, int availHeight) { + TextFitResult best = null; + + // Try standard font horizontal + TextFitResult stdH = tryFit(text, availWidth, availHeight, false, false); + if (best == null || stdH.score > best.score) best = stdH; + + // Try standard font vertical + TextFitResult stdV = tryFit(text, availHeight, availWidth, false, true); + if (stdV.score > best.score) best = stdV; + + // Only allow micro font when using scale 1 + if (this.textScale <= 1) { + TextFitResult microH = tryFit(text, availWidth, availHeight, true, false); + if (microH.score > best.score) best = microH; + + TextFitResult microV = tryFit(text, availHeight, availWidth, true, true); + if (microV.score > best.score) best = microV; + } + + return best; + } + + /** + * Try fitting text in given dimensions with specified font. + * Returns a fit result with quality score. + */ + private TextFitResult tryFit(String text, int width, int height, boolean useMicro, boolean vertical) { + int scale = useMicro ? 1 : Math.max(1, this.textScale); + int charWidth = useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH * scale; + int charSpacing = useMicro ? BitmapFont.MICRO_CHAR_SPACING : BitmapFont.CHAR_SPACING * scale; + int charHeight = useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT * scale; + int lineSpacing = useMicro ? 1 : 2 * scale; + + int fullCharWidth = charWidth + charSpacing; + int lineHeight = charHeight + lineSpacing; + + int textLen = text.length(); + int singleLineWidth = textLen * fullCharWidth - charSpacing; + + // Check if single line fits + if (singleLineWidth <= width && charHeight <= height) { + // Perfect fit - single line + int score = 100 + (useMicro ? 0 : 10); // Prefer standard font + return new TextFitResult(true, useMicro, vertical, 1, score); + } + + // Calculate multiline fit + int maxCharsPerLine = Math.max(1, width / fullCharWidth); + int linesNeeded = (int) Math.ceil((double) textLen / maxCharsPerLine); + int maxLines = Math.max(1, height / lineHeight); + + if (linesNeeded <= maxLines) { + // Fits with multiple lines + int score = 80 - linesNeeded * 5 + (useMicro ? 0 : 5); + return new TextFitResult(true, useMicro, vertical, linesNeeded, score); + } + + // Doesn't fit well - will be truncated + int score = 20 - (linesNeeded - maxLines) * 10 + (useMicro ? 5 : 0); // Prefer micro when truncating + return new TextFitResult(false, useMicro, vertical, maxLines, Math.max(0, score)); + } + + /** + * Render text using the calculated fit result. + * Handles both horizontal and vertical orientations. + */ + private void renderTextWithFit(String displayName, TextFitResult fit, + int tileWidth, int tileHeight, int offsetX, int offsetZ) { + if (fit.vertical) { + renderVerticalText(displayName, fit, tileWidth, tileHeight, offsetX, offsetZ); + } else { + renderHorizontalText(displayName, fit, tileWidth, tileHeight, offsetX, offsetZ); + } + } + + /** + * Render text horizontally (normal orientation). + */ + private void renderHorizontalText(String displayName, TextFitResult fit, + int tileWidth, int tileHeight, int offsetX, int offsetZ) { + int margin = 2; + int availWidth = tileWidth - margin * 2; + + int scale = fit.useMicro ? 1 : Math.max(1, this.textScale); + int charWidth = fit.useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH * scale; + int charSpacing = fit.useMicro ? BitmapFont.MICRO_CHAR_SPACING : BitmapFont.CHAR_SPACING * scale; + int charHeight = fit.useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT * scale; + int lineSpacing = fit.useMicro ? 1 : 2 * scale; + int fullCharWidth = charWidth + charSpacing; + + // Calculate line splitting + String[] lines; + if (fit.lines == 1) { + lines = new String[]{displayName}; + } else { + int maxCharsPerLine = Math.max(1, availWidth / fullCharWidth); + int actualLines = Math.min(fit.lines, (int) Math.ceil((double) displayName.length() / maxCharsPerLine)); + lines = BitmapFont.splitBalanced(displayName, actualLines); + } + + // Calculate total text height and starting Y + int totalHeight = lines.length * charHeight + (lines.length - 1) * lineSpacing; + int startY = (tileHeight - totalHeight) / 2; + + // Draw each line centered + for (String line : lines) { + int lineWidth = fit.useMicro ? BitmapFont.getMicroTextWidth(line) : BitmapFont.getTextWidth(line, scale); + int startX = (tileWidth - lineWidth) / 2; + + // Adjust for offset (when part of merged group) + int localX = startX - offsetX; + int localY = startY - offsetZ; + + drawTextWithOutlineClipped(line, localX, localY, tileWidth, tileHeight, fit.useMicro, scale); + startY += charHeight + lineSpacing; + } + } + + /** + * Render text vertically (rotated 90 degrees, reading top-to-bottom). + * Each character is on its own line. + */ + private void renderVerticalText(String displayName, TextFitResult fit, + int tileWidth, int tileHeight, int offsetX, int offsetZ) { + int scale = fit.useMicro ? 1 : Math.max(1, this.textScale); + int charWidth = fit.useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH * scale; + int charHeight = fit.useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT * scale; + int charSpacing = fit.useMicro ? 1 : 2 * scale; + + // For vertical text, each character is stacked + int totalHeight = displayName.length() * (charHeight + charSpacing) - charSpacing; + int startY = (tileHeight - totalHeight) / 2; + int startX = (tileWidth - charWidth) / 2; + + // Adjust for offset + int localX = startX - offsetX; + int localY = startY - offsetZ; + + // Draw each character as a separate "line" + for (int i = 0; i < displayName.length(); i++) { + String charStr = String.valueOf(displayName.charAt(i)); + drawTextWithOutlineClipped(charStr, localX, localY, tileWidth, tileHeight, fit.useMicro, scale); + localY += charHeight + charSpacing; + } + } + + /** + * Render text across a merged tile area. + * Only called from anchor tile. + */ + private void renderMergedText(String displayName, TextFitResult fit, + int mergedWidth, int mergedHeight, + int offsetX, int offsetZ, int tileWidth, int tileHeight) { + if (fit.vertical) { + renderMergedVerticalText(displayName, fit, mergedWidth, mergedHeight, + offsetX, offsetZ, tileWidth, tileHeight); + } else { + renderMergedHorizontalText(displayName, fit, mergedWidth, mergedHeight, + offsetX, offsetZ, tileWidth, tileHeight); + } + } + + /** + * Render horizontal text across merged tiles. + */ + private void renderMergedHorizontalText(String displayName, TextFitResult fit, + int mergedWidth, int mergedHeight, + int offsetX, int offsetZ, int tileWidth, int tileHeight) { + int margin = 2; + int availWidth = mergedWidth - margin * 2; + + int scale = fit.useMicro ? 1 : Math.max(1, this.textScale); + int charWidth = fit.useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH * scale; + int charSpacing = fit.useMicro ? BitmapFont.MICRO_CHAR_SPACING : BitmapFont.CHAR_SPACING * scale; + int charHeight = fit.useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT * scale; + int lineSpacing = fit.useMicro ? 1 : 2 * scale; + int fullCharWidth = charWidth + charSpacing; + + // Calculate line splitting + String[] lines; + if (fit.lines == 1) { + lines = new String[]{displayName}; + } else { + int maxCharsPerLine = Math.max(1, availWidth / fullCharWidth); + int actualLines = Math.min(fit.lines, (int) Math.ceil((double) displayName.length() / maxCharsPerLine)); + lines = BitmapFont.splitBalanced(displayName, actualLines); + } + + // Calculate total text height and starting Y in merged coordinates + int totalHeight = lines.length * charHeight + (lines.length - 1) * lineSpacing; + int mergedStartY = (mergedHeight - totalHeight) / 2; + + // Draw each line centered in merged area + for (String line : lines) { + int lineWidth = fit.useMicro ? BitmapFont.getMicroTextWidth(line) : BitmapFont.getTextWidth(line, scale); + int mergedStartX = (mergedWidth - lineWidth) / 2; + + // Convert to local tile coordinates + int localX = mergedStartX - offsetX; + int localY = mergedStartY - offsetZ; + + drawTextWithOutlineClipped(line, localX, localY, tileWidth, tileHeight, fit.useMicro, scale); + mergedStartY += charHeight + lineSpacing; + } + } + + /** + * Render vertical text across merged tiles. + */ + private void renderMergedVerticalText(String displayName, TextFitResult fit, + int mergedWidth, int mergedHeight, + int offsetX, int offsetZ, int tileWidth, int tileHeight) { + int scale = fit.useMicro ? 1 : Math.max(1, this.textScale); + int charWidth = fit.useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH * scale; + int charHeight = fit.useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT * scale; + int charSpacing = fit.useMicro ? 1 : 2 * scale; + + // For vertical text, each character is stacked + int totalHeight = displayName.length() * (charHeight + charSpacing) - charSpacing; + int mergedStartY = (mergedHeight - totalHeight) / 2; + int mergedStartX = (mergedWidth - charWidth) / 2; + + // Convert to local and draw + int localX = mergedStartX - offsetX; + int localY = mergedStartY - offsetZ; + + for (int i = 0; i < displayName.length(); i++) { + String charStr = String.valueOf(displayName.charAt(i)); + drawTextWithOutlineClipped(charStr, localX, localY, tileWidth, tileHeight, fit.useMicro, scale); + localY += charHeight + charSpacing; + } + } + + /** + * Draw text with outline, clipped to the tile boundaries. + * Handles text that may be partially outside the visible area. + * + * @param text The text to draw + * @param startX Starting X in local tile coordinates (may be negative) + * @param startY Starting Y in local tile coordinates (may be negative) + * @param tileWidth Width of the tile + * @param tileHeight Height of the tile + * @param useMicro Whether to use micro font + * @param scale Scale factor for standard font rendering + */ + private void drawTextWithOutlineClipped(String text, int startX, int startY, + int tileWidth, int tileHeight, boolean useMicro, int scale) { + if (text == null || text.isEmpty()) return; + + int effectiveScale = useMicro ? 1 : Math.max(1, scale); + int charWidth = useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH * effectiveScale; + int charHeight = useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT * effectiveScale; + int charSpacing = useMicro ? BitmapFont.MICRO_CHAR_SPACING : BitmapFont.CHAR_SPACING * effectiveScale; + int fullCharWidth = charWidth + charSpacing; + + // Calculate which characters are potentially visible + int textWidth = text.length() * fullCharWidth - charSpacing; + int textHeight = charHeight; + + // Early exit if text is completely outside tile (with outline margin) + int outlineMargin = useMicro ? 1 : Math.max(1, effectiveScale / 2); + if (startX + textWidth + outlineMargin < 0 || startX - outlineMargin >= tileWidth || + startY + textHeight + outlineMargin < 0 || startY - outlineMargin >= tileHeight) { + return; + } + + // Draw outline first (8 directions) + for (int dx = -outlineMargin; dx <= outlineMargin; dx++) { + for (int dy = -outlineMargin; dy <= outlineMargin; dy++) { + if (dx != 0 || dy != 0) { + drawTextClipped(text, startX + dx, startY + dy, tileWidth, tileHeight, + BitmapFont.BLACK, useMicro, charWidth, charSpacing, effectiveScale); + } + } + } + + // Draw main text on top + drawTextClipped(text, startX, startY, tileWidth, tileHeight, + BitmapFont.WHITE, useMicro, charWidth, charSpacing, effectiveScale); + } + + /** + * Draw text clipped to tile boundaries. + * Only draws pixels that fall within the tile. + */ + private void drawTextClipped(String text, int startX, int startY, int tileWidth, int tileHeight, + int color, boolean useMicro, int charWidth, int charSpacing, int scale) { + int x = startX; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + + // Skip characters completely outside tile + if (x + charWidth > 0 && x < tileWidth) { + drawCharClipped(c, x, startY, tileWidth, tileHeight, color, useMicro, scale); + } + + x += charWidth + charSpacing; + + // Early exit if we've passed the right edge + if (x >= tileWidth) break; + } + } + + /** + * Draw a single character clipped to tile boundaries. + */ + private void drawCharClipped(char c, int x, int y, int tileWidth, int tileHeight, + int color, boolean useMicro, int scale) { + int[] glyph = useMicro ? getMicroGlyph(c) : getStandardGlyph(c); + if (glyph == null) return; + + int baseCharWidth = useMicro ? BitmapFont.MICRO_CHAR_WIDTH : BitmapFont.CHAR_WIDTH; + int baseCharHeight = useMicro ? BitmapFont.MICRO_CHAR_HEIGHT : BitmapFont.CHAR_HEIGHT; + int effectiveScale = useMicro ? 1 : Math.max(1, scale); + + if (useMicro || effectiveScale == 1) { + for (int row = 0; row < baseCharHeight; row++) { + int py = y + row; + if (py < 0 || py >= tileHeight) continue; + + int rowBits = glyph[row]; + for (int col = 0; col < baseCharWidth; col++) { + int px = x + col; + if (px < 0 || px >= tileWidth) continue; + + boolean pixelOn = (rowBits & (1 << (baseCharWidth - 1 - col))) != 0; + if (pixelOn) { + this.image.data[py * tileWidth + px] = color; + } + } + } + return; + } + + for (int row = 0; row < baseCharHeight; row++) { + int rowBits = glyph[row]; + for (int col = 0; col < baseCharWidth; col++) { + boolean pixelOn = (rowBits & (1 << (baseCharWidth - 1 - col))) != 0; + if (!pixelOn) continue; + + int baseX = x + col * effectiveScale; + int baseY = y + row * effectiveScale; + for (int sy = 0; sy < effectiveScale; sy++) { + int py = baseY + sy; + if (py < 0 || py >= tileHeight) continue; + for (int sx = 0; sx < effectiveScale; sx++) { + int px = baseX + sx; + if (px < 0 || px >= tileWidth) continue; + this.image.data[py * tileWidth + px] = color; + } + } + } + } + } + + /** + * Get glyph data for a character (standard 5x7 font). + */ + private static int[] getStandardGlyph(char c) { + // Access via reflection-like approach or hardcode common chars + // For now, use a helper that matches BitmapFont's internal structure + return BitmapFont.getGlyph(c); + } + + /** + * Get glyph data for a character (micro 3x5 font). + */ + private static int[] getMicroGlyph(char c) { + return BitmapFont.getMicroGlyph(c); + } + private static float shadeFromHeights(int blockPixelX, int blockPixelZ, int blockPixelWidth, int blockPixelHeight, short height, short north, short south, short west, short east, short northWest, short northEast, short southWest, short southEast) { @@ -557,4 +1060,4 @@ public void multiply(float value) { this.b = Math.min(255, Math.max(0, (int) ((float) this.b * value))); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/easyclaims/map/EasyClaimsChunkWorldMap.java b/src/main/java/com/easyclaims/map/EasyClaimsChunkWorldMap.java index 249c7f4..4ce1b63 100644 --- a/src/main/java/com/easyclaims/map/EasyClaimsChunkWorldMap.java +++ b/src/main/java/com/easyclaims/map/EasyClaimsChunkWorldMap.java @@ -1,21 +1,23 @@ package com.easyclaims.map; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + import com.hypixel.hytale.protocol.packets.worldmap.MapMarker; import com.hypixel.hytale.protocol.packets.worldmap.UpdateWorldMapSettings; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.map.WorldMap; import com.hypixel.hytale.server.core.universe.world.worldmap.IWorldMap; import com.hypixel.hytale.server.core.universe.world.worldmap.WorldMapSettings; + import it.unimi.dsi.fastutil.longs.LongIterator; import it.unimi.dsi.fastutil.longs.LongSet; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - /** * Custom world map implementation that renders claims as colored overlays. * Based on SimpleClaims' SimpleClaimsChunkWorldMap. + * Uses settings compatible with BetterMap's defaults. */ public class EasyClaimsChunkWorldMap implements IWorldMap { @@ -23,6 +25,7 @@ public class EasyClaimsChunkWorldMap implements IWorldMap { @Override public WorldMapSettings getWorldMapSettings() { + // Default settings - BetterMap will override these via reflection if installed UpdateWorldMapSettings settingsPacket = new UpdateWorldMapSettings(); settingsPacket.defaultScale = 128.0F; settingsPacket.minScale = 64.0F; diff --git a/src/main/java/com/easyclaims/util/Messages.java b/src/main/java/com/easyclaims/util/Messages.java index 364520d..44f00ae 100644 --- a/src/main/java/com/easyclaims/util/Messages.java +++ b/src/main/java/com/easyclaims/util/Messages.java @@ -283,6 +283,18 @@ public static Message pvpModeChanged(boolean pvpInClaims) { } } + public static Message claimMapVisibilityChanged(boolean visible) { + if (visible) { + return Message.raw("Claim overlays on map: VISIBLE").color(GREEN); + } else { + return Message.raw("Claim overlays on map: HIDDEN").color(YELLOW); + } + } + + public static Message claimMapRefreshing() { + return Message.raw("Refreshing world map...").color(GRAY); + } + public static Message bufferZoneBlocked(int bufferSize) { return Message.raw("Cannot claim here - too close to another player's claim! (Buffer: " + bufferSize + " chunks)").color(RED); }