Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/main/java/world/bentobox/chunkblock/chunks/ChunkMap.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cell> 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<Cell> 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<Cell> 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;
Expand Down
182 changes: 164 additions & 18 deletions src/main/java/world/bentobox/chunkblock/panels/ChunksDialog.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,33 @@
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;
import world.bentobox.chunkblock.chunks.ChunkMap.Cell;

/**
* 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.
* <p>
* 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
Expand All @@ -56,16 +62,47 @@

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;
}
}

/**
Expand All @@ -78,22 +115,22 @@
* 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;
}
Expand All @@ -120,19 +157,117 @@
String.valueOf(max)))
.canCloseWithEscape(true).afterAction(DialogBase.DialogAfterAction.CLOSE).body(body).build();

List<ActionButton> buttons = cells().stream().map(this::button).toList();
DialogType type = DialogType.multiAction(buttons).columns(2 * radius + 1)
int columns = 2 * radius + 1;
List<ActionButton> 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<ActionButton> controlRow(int columns) {
List<ActionButton> 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<Cell> cells() {
return ChunkMap.cells(addon, island, user.getLocation(), radius);
return ChunkMap.cells(addon, island, user.getLocation(), radius, viewDx, viewDz);
}

private ActionButton button(Cell cell) {
Expand All @@ -148,9 +283,11 @@
* 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));
}

/**
Expand Down Expand Up @@ -184,6 +321,15 @@
return value > 0 ? "+" + value : String.valueOf(value);
}

private boolean isPlayerOnIsland() {
return user.getLocation() != null && island.getWorld() != null

Check warning on line 325 in src/main/java/world/bentobox/chunkblock/panels/ChunksDialog.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this expression which always evaluates to "true"

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_ChunkBlock&issues=AaAvREmnmR300CbmvkdW&open=AaAvREmnmR300CbmvkdW&pullRequest=49
&& 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,
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ chunkblock:
# These are MiniMessage, which is what new text should use — the old &-codes still work.
dialog:
close: "<white>Close"
view-island: "<gold>Center on your island"
view-player: "<aqua>Center on your position"
arrow-to-island: "<gold>Your island center is this way"
arrow-to-player: "<aqua>You are this way"
tooltip:
center: "<gold>The center chunk — your magic block is here."
owned: "<green>Chunk [x], [z] — yours."
Expand Down
Loading
Loading