diff --git a/src/main/java/world/bentobox/chunkblock/chunks/ChunkMap.java b/src/main/java/world/bentobox/chunkblock/chunks/ChunkMap.java index f85f299..76c065a 100644 --- a/src/main/java/world/bentobox/chunkblock/chunks/ChunkMap.java +++ b/src/main/java/world/bentobox/chunkblock/chunks/ChunkMap.java @@ -57,26 +57,43 @@ private ChunkMap() { /** * Maps the territory around an island, row by row from north to south and west to east - * within a row — the order both maps draw in. + * within a row — the order both maps draw in. The viewport is centered on the island + * center chunk. * * @param addon the addon * @param island the island whose territory is mapped * @param viewer where the player is standing, or null if they are nowhere on the map - * @param radius how many chunks out from the center the map reaches + * @param radius how many chunks out from the viewport center the map reaches * @return the cells of a square map (2 * radius + 1) chunks across */ public static List cells(@NonNull ChunkBlock addon, @NonNull Island island, @Nullable Location viewer, int radius) { + return cells(addon, island, viewer, radius, 0, 0); + } + + /** + * Maps the territory around an island with a shifted viewport center. The viewport is + * centered on the chunk at {@code (viewDx, viewDz)} relative to the island center. + * + * @param addon the addon + * @param island the island whose territory is mapped + * @param viewer where the player is standing, or null if they are nowhere on the map + * @param radius how many chunks out from the viewport center the map reaches + * @param viewDx viewport center chunk offset east of the island center + * @param viewDz viewport center chunk offset south of the island center + * @return the cells of a square map (2 * radius + 1) chunks across + */ + public static List cells(@NonNull ChunkBlock addon, @NonNull Island island, @Nullable Location viewer, + int radius, int viewDx, int viewDz) { ChunkManager cm = addon.getChunkManager(); int centerChunkX = island.getCenter().getBlockX() >> 4; int centerChunkZ = island.getCenter().getBlockZ() >> 4; - // A player who is not in this world stands on no chunk of the map boolean sameWorld = viewer != null && Util.sameWorld(island.getWorld(), viewer.getWorld()); int playerDx = sameWorld ? (viewer.getBlockX() >> 4) - centerChunkX : Integer.MIN_VALUE; int playerDz = sameWorld ? (viewer.getBlockZ() >> 4) - centerChunkZ : Integer.MIN_VALUE; List cells = new ArrayList<>(); - for (int dz = -radius; dz <= radius; dz++) { - for (int dx = -radius; dx <= radius; dx++) { + for (int dz = viewDz - radius; dz <= viewDz + radius; dz++) { + for (int dx = viewDx - radius; dx <= viewDx + radius; dx++) { Kind kind; if (dx == 0 && dz == 0) { kind = Kind.CENTER; diff --git a/src/main/java/world/bentobox/chunkblock/panels/ChunksDialog.java b/src/main/java/world/bentobox/chunkblock/panels/ChunksDialog.java index ae23b3f..5b33c1b 100644 --- a/src/main/java/world/bentobox/chunkblock/panels/ChunksDialog.java +++ b/src/main/java/world/bentobox/chunkblock/panels/ChunksDialog.java @@ -17,9 +17,11 @@ import io.papermc.paper.registry.data.dialog.type.DialogType; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.bentobox.api.dialogs.Dialogs; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.chunks.ChunkManager; import world.bentobox.chunkblock.chunks.ChunkMap; @@ -27,17 +29,21 @@ /** * The territory map of {@code /ch chunks} drawn as a dialog: one button per chunk, laid - * out in a grid. Chat renders a glyph grid differently on every client — font, chat width - * and scale all pull it out of shape — whereas dialog buttons are fixed-size boxes that - * look the same everywhere, and can carry a tooltip explaining the chunk under the mouse. - *

- * The map is read-only: chunks are still claimed by hitting the border, so clicking a - * chunk only reports what it is and reopens the map. + * out in a grid. When the island's territory can exceed the 13-wide viewport, a control + * row is prepended with two view-mode buttons (island center / player position) and a + * directional arrow pointing toward the off-screen target. * * @author tastybento */ public class ChunksDialog { + /** + * Whether the viewport is centered on the island center or on the player's position. + */ + enum ViewMode { + ISLAND_CENTER, PLAYER_CENTER + } + /** * Widest map that still fits the dialog. A grid this wide is {@value #MAX_RADIUS} * 2 * + 1 buttons across, which is as much as the dialog screen holds before the outer @@ -56,16 +62,47 @@ public class ChunksDialog { private static final String REFERENCE = "chunkblock.chunks.dialog."; + /** Direction glyphs indexed by sector (0 = east, rotating counter-clockwise). */ + private static final String[] ARROWS = { "▶", "↗", "▲", "↖", "◀", "↙", "▼", "↘" }; + private final ChunkBlock addon; private final User user; private final Island island; private final int radius; + private final ViewMode viewMode; + private final int viewDx; + private final int viewDz; + private final boolean scrollable; ChunksDialog(ChunkBlock addon, User user, Island island) { + this(addon, user, island, ViewMode.ISLAND_CENTER); + } + + ChunksDialog(ChunkBlock addon, User user, Island island, ViewMode viewMode) { this.addon = addon; this.user = user; this.island = island; - this.radius = Math.min(MAX_RADIUS, addon.getChunkManager().currentRing(island) + 1); + this.viewMode = viewMode; + + ChunkManager cm = addon.getChunkManager(); + this.scrollable = cm.maxRingRadius(island) > MAX_RADIUS; + + if (scrollable) { + this.radius = MAX_RADIUS; + if (viewMode == ViewMode.PLAYER_CENTER && isPlayerOnIsland()) { + int centerChunkX = island.getCenter().getBlockX() >> 4; + int centerChunkZ = island.getCenter().getBlockZ() >> 4; + this.viewDx = (user.getLocation().getBlockX() >> 4) - centerChunkX; + this.viewDz = (user.getLocation().getBlockZ() >> 4) - centerChunkZ; + } else { + this.viewDx = 0; + this.viewDz = 0; + } + } else { + this.radius = Math.min(MAX_RADIUS, cm.currentRing(island) + 1); + this.viewDx = 0; + this.viewDz = 0; + } } /** @@ -78,22 +115,22 @@ public class ChunksDialog { * which case the caller should fall back to the chat map */ public static boolean show(@NonNull ChunkBlock addon, @NonNull User user, @NonNull Island island) { - return show(addon, user, island, null); + return show(addon, user, island, ViewMode.ISLAND_CENTER, null); } /** + * @param viewMode which point the viewport is centered on * @param selection the chunk description to show above the map, or null for none */ - private static boolean show(ChunkBlock addon, User user, Island island, @Nullable Component selection) { + private static boolean show(ChunkBlock addon, User user, Island island, ViewMode viewMode, + @Nullable Component selection) { if (!Dialogs.isSupported() || !user.isPlayer() || user.getPlayer() == null) { return false; } try { - new ChunksDialog(addon, user, island).open(selection); + new ChunksDialog(addon, user, island, viewMode).open(selection); return true; } catch (Exception | LinkageError e) { - // A server that reports dialog support but cannot build one is no reason to - // leave the player with nothing — the caller falls back to the chat map addon.logError("Could not show the chunks dialog: " + e.getMessage()); return false; } @@ -120,19 +157,117 @@ private void open(@Nullable Component selection) { String.valueOf(max))) .canCloseWithEscape(true).afterAction(DialogBase.DialogAfterAction.CLOSE).body(body).build(); - List buttons = cells().stream().map(this::button).toList(); - DialogType type = DialogType.multiAction(buttons).columns(2 * radius + 1) + int columns = 2 * radius + 1; + List buttons = new ArrayList<>(); + if (scrollable) { + buttons.addAll(controlRow(columns)); + } + buttons.addAll(cells().stream().map(this::button).toList()); + + DialogType type = DialogType.multiAction(buttons).columns(columns) .exitAction(ActionButton.create(text(REFERENCE + "close"), null, CLOSE_BUTTON_WIDTH, null)).build(); user.getPlayer().showDialog(Dialog.create(factory -> factory.empty().base(base).type(type))); } + // ------------------------------------------------------------------ + // Control row (view-mode toggle + directional arrow) + // ------------------------------------------------------------------ + + private List controlRow(int columns) { + List row = new ArrayList<>(columns); + for (int i = 0; i < columns; i++) { + if (i == 0) { + row.add(modeButton(ViewMode.ISLAND_CENTER, "◎", "◉", NamedTextColor.GOLD, + REFERENCE + "view-island")); + } else if (i == columns - 1) { + row.add(modeButton(ViewMode.PLAYER_CENTER, "◇", "◆", NamedTextColor.AQUA, + REFERENCE + "view-player")); + } else if (i == columns / 2) { + row.add(directionArrowButton()); + } else { + row.add(spacerButton()); + } + } + return row; + } + + private ActionButton modeButton(ViewMode mode, String inactiveGlyph, String activeGlyph, + NamedTextColor color, String tooltipKey) { + boolean active = viewMode == mode; + String glyph = active ? activeGlyph : inactiveGlyph; + NamedTextColor buttonColor = active ? NamedTextColor.WHITE : color; + return ActionButton.builder(Component.text(glyph, buttonColor)) + .tooltip(text(tooltipKey)).width(BUTTON_WIDTH) + .action(DialogAction.customClick((view, audience) -> switchMode(mode), + ClickCallback.Options.builder().uses(1).lifetime(CALLBACK_LIFETIME).build())) + .build(); + } + + private ActionButton spacerButton() { + return ActionButton.builder(Component.text("─", NamedTextColor.DARK_GRAY)).width(BUTTON_WIDTH).build(); + } + + private ActionButton directionArrowButton() { + int targetDx; + int targetDz; + NamedTextColor arrowColor; + String tooltipKey; + ViewMode targetMode; + + if (viewMode == ViewMode.ISLAND_CENTER) { + if (!isPlayerOnIsland()) { + return spacerButton(); + } + int centerChunkX = island.getCenter().getBlockX() >> 4; + int centerChunkZ = island.getCenter().getBlockZ() >> 4; + targetDx = (user.getLocation().getBlockX() >> 4) - centerChunkX; + targetDz = (user.getLocation().getBlockZ() >> 4) - centerChunkZ; + arrowColor = NamedTextColor.AQUA; + tooltipKey = REFERENCE + "arrow-to-player"; + targetMode = ViewMode.PLAYER_CENTER; + } else { + targetDx = 0; + targetDz = 0; + arrowColor = NamedTextColor.GOLD; + tooltipKey = REFERENCE + "arrow-to-island"; + targetMode = ViewMode.ISLAND_CENTER; + } + + int relDx = targetDx - viewDx; + int relDz = targetDz - viewDz; + if (Math.abs(relDx) <= radius && Math.abs(relDz) <= radius) { + return ActionButton.builder(Component.text("•", NamedTextColor.DARK_GRAY)).width(BUTTON_WIDTH).build(); + } + + String arrow = directionGlyph(relDx, relDz); + return ActionButton.builder(Component.text(arrow, arrowColor)) + .tooltip(text(tooltipKey)).width(BUTTON_WIDTH) + .action(DialogAction.customClick((view, audience) -> switchMode(targetMode), + ClickCallback.Options.builder().uses(1).lifetime(CALLBACK_LIFETIME).build())) + .build(); + } + + /** + * Returns the arrow glyph for the direction from the viewport center to the target. + * Divides the plane into eight 45-degree sectors starting from east. + */ + static String directionGlyph(int relDx, int relDz) { + double angle = Math.atan2(-relDz, relDx); + int sector = (int) Math.round(angle / (Math.PI / 4)); + return ARROWS[((sector % 8) + 8) % 8]; + } + + // ------------------------------------------------------------------ + // Map buttons + // ------------------------------------------------------------------ + /** * The map, row by row from north to south — the same reading order the buttons are laid * out in, so the grid comes out with north at the top. */ List cells() { - return ChunkMap.cells(addon, island, user.getLocation(), radius); + return ChunkMap.cells(addon, island, user.getLocation(), radius, viewDx, viewDz); } private ActionButton button(Cell cell) { @@ -148,9 +283,11 @@ private ActionButton button(Cell cell) { * closes the dialog, so a map that stays put has to be shown again. */ private void reopen(Component selection) { - // Dialog callbacks may arrive off the main thread, and everything the map reads is - // island data - Bukkit.getScheduler().runTask(addon.getPlugin(), () -> show(addon, user, island, selection)); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> show(addon, user, island, viewMode, selection)); + } + + private void switchMode(ViewMode mode) { + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> show(addon, user, island, mode, null)); } /** @@ -184,6 +321,15 @@ private static String offset(int value) { return value > 0 ? "+" + value : String.valueOf(value); } + private boolean isPlayerOnIsland() { + return user.getLocation() != null && island.getWorld() != null + && Util.sameWorld(island.getWorld(), user.getLocation().getWorld()); + } + + boolean isScrollable() { + return scrollable; + } + /** * Translates a locale key straight to a component. Going through the user rather than * parsing the translated string here keeps every message on BentoBox's own path, diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 483e52b..6796b97 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -66,6 +66,10 @@ chunkblock: # These are MiniMessage, which is what new text should use — the old &-codes still work. dialog: close: "Close" + view-island: "Center on your island" + view-player: "Center on your position" + arrow-to-island: "Your island center is this way" + arrow-to-player: "You are this way" tooltip: center: "The center chunk — your magic block is here." owned: "Chunk [x], [z] — yours." diff --git a/src/test/java/world/bentobox/chunkblock/panels/ChunksDialogTest.java b/src/test/java/world/bentobox/chunkblock/panels/ChunksDialogTest.java index 8f6735a..5e94db9 100644 --- a/src/test/java/world/bentobox/chunkblock/panels/ChunksDialogTest.java +++ b/src/test/java/world/bentobox/chunkblock/panels/ChunksDialogTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -15,12 +16,14 @@ import world.bentobox.chunkblock.CommonTestSetup; import world.bentobox.chunkblock.Settings; import world.bentobox.chunkblock.chunks.ChunkManager; +import world.bentobox.chunkblock.chunks.ChunkMap.Cell; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; import world.bentobox.chunkblock.listeners.BlockListener; /** - * Tests how far the dialog map reaches and when it declines to show at all. What each chunk - * is and how it is drawn belongs to the shared grid, and is covered by {@code ChunkMapTest}. + * Tests how far the dialog map reaches, when it scrolls, and when it declines to show at + * all. What each chunk is and how it is drawn belongs to the shared grid, and is covered + * by {@code ChunkMapTest}. */ class ChunksDialogTest extends CommonTestSetup { @@ -51,7 +54,6 @@ public void setUp() throws Exception { when(island.getWorld()).thenReturn(world); when(location.getBlockX()).thenReturn(8); when(location.getBlockZ()).thenReturn(8); - when(island.getProtectionRange()).thenReturn(240); when(playerLocation.getBlockX()).thenReturn(8); when(playerLocation.getBlockZ()).thenReturn(8); when(playerLocation.getWorld()).thenReturn(world); @@ -60,11 +62,75 @@ public void setUp() throws Exception { } @Test - void testAFreshIslandShowsTheRingAroundTheCenter() { - // One claimed chunk, so the map reaches one ring out — 3 x 3 + void testSmallIslandShowsTheRingAroundTheCenter() { + when(island.getProtectionRange()).thenReturn(100); + // maxRingRadius = (100-8)/16 = 5, not scrollable assertEquals(9, new ChunksDialog(addon, user, island).cells().size()); } + @Test + void testScrollableIslandShowsFullViewport() { + when(island.getProtectionRange()).thenReturn(240); + int width = 2 * ChunksDialog.MAX_RADIUS + 1; + assertEquals(width * width, new ChunksDialog(addon, user, island).cells().size()); + } + + @Test + void testIsScrollableWhenTerritoryExceedsViewport() { + when(island.getProtectionRange()).thenReturn(240); + assertTrue(new ChunksDialog(addon, user, island).isScrollable()); + } + + @Test + void testIsNotScrollableWhenTerritoryFitsViewport() { + when(island.getProtectionRange()).thenReturn(100); + assertFalse(new ChunksDialog(addon, user, island).isScrollable()); + } + + @Test + void testPlayerCenteredViewportShiftsMap() { + when(island.getProtectionRange()).thenReturn(240); + when(playerLocation.getBlockX()).thenReturn(8 + 160); + when(playerLocation.getBlockZ()).thenReturn(8); + level = 100000; + // Claim enough to reach ring 10 + for (int d = 1; d <= 10; d++) { + cm.claim(island, d, 0); + } + ChunksDialog dialog = new ChunksDialog(addon, user, island, ChunksDialog.ViewMode.PLAYER_CENTER); + int width = 2 * ChunksDialog.MAX_RADIUS + 1; + assertEquals(width * width, dialog.cells().size()); + // The player is at chunk offset +10, so the viewport should be centered there. + // The center chunk (0,0) should be visible if it's within radius of the viewport center. + // viewDx=10, radius=6 → visible range is 4..16, so island center at 0 is NOT visible. + boolean centerVisible = dialog.cells().stream() + .anyMatch(c -> c.dx() == 0 && c.dz() == 0); + assertFalse(centerVisible); + } + + @Test + void testNothingIsShownWhenTheUserIsNotAPlayer() { + when(island.getProtectionRange()).thenReturn(240); + when(user.isPlayer()).thenReturn(false); + assertFalse(ChunksDialog.show(addon, user, island)); + } + + @Test + void testDirectionGlyphCardinals() { + assertEquals("▶", ChunksDialog.directionGlyph(10, 0)); + assertEquals("◀", ChunksDialog.directionGlyph(-10, 0)); + assertEquals("▲", ChunksDialog.directionGlyph(0, -10)); + assertEquals("▼", ChunksDialog.directionGlyph(0, 10)); + } + + @Test + void testDirectionGlyphDiagonals() { + assertEquals("↗", ChunksDialog.directionGlyph(10, -10)); + assertEquals("↘", ChunksDialog.directionGlyph(10, 10)); + assertEquals("↖", ChunksDialog.directionGlyph(-10, -10)); + assertEquals("↙", ChunksDialog.directionGlyph(-10, 10)); + } + @Test void testMapIsCappedAtTheWidestGridTheDialogHolds() { when(island.getProtectionRange()).thenReturn(2000); @@ -77,8 +143,13 @@ void testMapIsCappedAtTheWidestGridTheDialogHolds() { } @Test - void testNothingIsShownWhenTheUserIsNotAPlayer() { - when(user.isPlayer()).thenReturn(false); - assertFalse(ChunksDialog.show(addon, user, island)); + void testPlayerCenteredFallsBackToIslandCenterWhenNotOnIsland() { + when(island.getProtectionRange()).thenReturn(240); + when(playerLocation.getWorld()).thenReturn(null); + // Player is not in the island world → viewport should center on island + ChunksDialog dialog = new ChunksDialog(addon, user, island, ChunksDialog.ViewMode.PLAYER_CENTER); + boolean centerVisible = dialog.cells().stream() + .anyMatch(c -> c.dx() == 0 && c.dz() == 0); + assertTrue(centerVisible); } }