diff --git a/core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java b/core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java
new file mode 100644
index 000000000..9df922a78
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java
@@ -0,0 +1,279 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.components.MapStyle;
+import com.volmit.iris.core.gui.components.RenderType;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.util.Locale;
+
+/**
+ * Keeps the picture of the map from just before a hotload, and shows it next to the new one: either split by a
+ * draggable divider, or with everything that stayed the same faded out.
+ */
+final class HotloadComparison {
+ private static final double DEFAULT_SWIPE_FRACTION = 0.5D;
+ private static final int DIVIDER_GRAB_DISTANCE = 8;
+ private static final int DIVIDER_HANDLE_RADIUS = 9;
+ private static final int UNCHANGED_FADE_TARGET = 0x1E1F22;
+ private static final double UNCHANGED_FADE = 0.78D;
+ private static final Color DIVIDER = new Color(255, 255, 255, 220);
+
+ private BufferedImage before;
+ private Scene beforeScene;
+ private Mode mode = Mode.OFF;
+ private double swipeFraction = DEFAULT_SWIPE_FRACTION;
+ private BufferedImage changes;
+ private ChangesKey changesKey;
+ private double changedShare;
+
+ enum Mode {
+ OFF,
+ SWIPE,
+ CHANGES;
+
+ Mode next() {
+ Mode[] modes = values();
+ return modes[(ordinal() + 1) % modes.length];
+ }
+ }
+
+ /**
+ * What a map picture shows, so the before picture is only laid over a now picture of the same kind and zoom.
+ */
+ record Scene(RenderType type, MapStyle style, double blocksPerPixel, double centerX, double centerZ) {
+ boolean looksLike(Scene other) {
+ return type == other.type && style.equals(other.style);
+ }
+ }
+
+ boolean hasBefore() {
+ return before != null;
+ }
+
+ Mode mode() {
+ return mode;
+ }
+
+ void setMode(Mode nextMode) {
+ mode = nextMode;
+ }
+
+ void capture(BufferedImage layer, Scene scene) {
+ BufferedImage copy = new BufferedImage(layer.getWidth(), layer.getHeight(), BufferedImage.TYPE_INT_RGB);
+ copy.setAccelerationPriority(0F);
+ Graphics2D graphics = copy.createGraphics();
+ try {
+ graphics.drawImage(layer, 0, 0, null);
+ } finally {
+ graphics.dispose();
+ }
+ before = copy;
+ beforeScene = scene;
+ changes = null;
+ changesKey = null;
+ }
+
+ void forget() {
+ before = null;
+ beforeScene = null;
+ changes = null;
+ changesKey = null;
+ mode = Mode.OFF;
+ }
+
+ boolean isShowing() {
+ return mode != Mode.OFF && before != null;
+ }
+
+ boolean isNearDivider(int screenX, int width) {
+ return isShowing() && mode == Mode.SWIPE
+ && Math.abs(screenX - dividerX(width)) <= StudioTheme.scale(DIVIDER_GRAB_DISTANCE);
+ }
+
+ void moveDivider(int screenX, int width) {
+ swipeFraction = Math.max(0D, Math.min(1D, screenX / (double) Math.max(1, width)));
+ }
+
+ /**
+ * Draws the comparison in place of the plain map picture.
+ *
+ * @param nowVersion changes whenever the now picture is redrawn, so the change mask is only rebuilt when needed
+ * @param nowComplete whether every tile of the now picture has been drawn; changes are only counted then
+ */
+ void paint(Graphics2D canvas, BufferedImage now, long nowVersion, Scene nowScene, boolean nowComplete) {
+ canvas.drawImage(now, 0, 0, null);
+ if (!beforeScene.looksLike(nowScene)) {
+ drawCaption(canvas, now.getWidth(), "Before and after needs the view and layers the hotload had");
+ return;
+ }
+ if (mode == Mode.SWIPE) {
+ paintSwipe(canvas, now.getWidth(), now.getHeight(), nowScene);
+ return;
+ }
+ if (beforeScene.blocksPerPixel() != nowScene.blocksPerPixel()) {
+ drawCaption(canvas, now.getWidth(), "Changes needs the zoom the hotload had");
+ return;
+ }
+ if (!nowComplete) {
+ drawCaption(canvas, now.getWidth(), "Comparing once the map has finished drawing");
+ return;
+ }
+ int offsetX = (int) Math.round(beforeScreenX(now.getWidth(), nowScene));
+ int offsetZ = (int) Math.round(beforeScreenZ(now.getHeight(), nowScene));
+ canvas.drawImage(changesImage(now, nowVersion, offsetX, offsetZ), 0, 0, null);
+ drawCaption(canvas, now.getWidth(), changesCaption());
+ }
+
+ private void paintSwipe(Graphics2D canvas, int width, int height, Scene now) {
+ int dividerX = dividerX(width);
+ double scale = beforeScene.blocksPerPixel() / now.blocksPerPixel();
+ Graphics2D beforeSide = (Graphics2D) canvas.create();
+ try {
+ beforeSide.clipRect(0, 0, dividerX, height);
+ beforeSide.drawImage(before,
+ (int) Math.round(beforeScreenX(width, now)),
+ (int) Math.round(beforeScreenZ(height, now)),
+ (int) Math.round(before.getWidth() * scale),
+ (int) Math.round(before.getHeight() * scale),
+ null);
+ } finally {
+ beforeSide.dispose();
+ }
+ canvas.setColor(DIVIDER);
+ canvas.setStroke(new BasicStroke(StudioTheme.scale(2)));
+ canvas.drawLine(dividerX, 0, dividerX, height);
+ int radius = StudioTheme.scale(DIVIDER_HANDLE_RADIUS);
+ canvas.fillOval(dividerX - radius, height / 2 - radius, radius * 2, radius * 2);
+ canvas.setColor(StudioTheme.EDITOR_BACKGROUND);
+ int gripHalfHeight = radius / 2;
+ int gripGap = Math.max(1, radius / 4);
+ canvas.drawLine(dividerX - gripGap, height / 2 - gripHalfHeight, dividerX - gripGap, height / 2 + gripHalfHeight);
+ canvas.drawLine(dividerX + gripGap, height / 2 - gripHalfHeight, dividerX + gripGap, height / 2 + gripHalfHeight);
+ drawSideLabel(canvas, "Before", dividerX - StudioTheme.scale(12), 1D);
+ drawSideLabel(canvas, "Now", dividerX + StudioTheme.scale(12), 0D);
+ }
+
+ private int dividerX(int width) {
+ return (int) Math.round(width * swipeFraction);
+ }
+
+ // Where the before picture's top left corner lands on screen now, after any panning, zooming or resizing since.
+ private double beforeScreenX(int width, Scene now) {
+ double beforeLeftWorld = beforeScene.centerX() - before.getWidth() * 0.5D * beforeScene.blocksPerPixel();
+ return width * 0.5D + (beforeLeftWorld - now.centerX()) / now.blocksPerPixel();
+ }
+
+ private double beforeScreenZ(int height, Scene now) {
+ double beforeTopWorld = beforeScene.centerZ() - before.getHeight() * 0.5D * beforeScene.blocksPerPixel();
+ return height * 0.5D + (beforeTopWorld - now.centerZ()) / now.blocksPerPixel();
+ }
+
+ private BufferedImage changesImage(BufferedImage now, long nowVersion, int offsetX, int offsetZ) {
+ ChangesKey key = new ChangesKey(nowVersion, offsetX, offsetZ, now.getWidth(), now.getHeight());
+ if (changes != null && key.equals(changesKey)) {
+ return changes;
+ }
+ int width = now.getWidth();
+ int height = now.getHeight();
+ int[] nowPixels = pixels(now);
+ int[] beforePixels = pixels(before);
+ BufferedImage mask = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ mask.setAccelerationPriority(0F);
+ int[] maskPixels = pixels(mask);
+ long compared = 0L;
+ long changed = 0L;
+ for (int y = 0; y < height; y++) {
+ int beforeY = y - offsetZ;
+ boolean rowInBefore = beforeY >= 0 && beforeY < before.getHeight();
+ for (int x = 0; x < width; x++) {
+ int index = y * width + x;
+ int nowColor = nowPixels[index] & 0xFFFFFF;
+ int beforeX = x - offsetX;
+ boolean inBefore = rowInBefore && beforeX >= 0 && beforeX < before.getWidth();
+ boolean differs = inBefore && (beforePixels[beforeY * before.getWidth() + beforeX] & 0xFFFFFF) != nowColor;
+ if (inBefore) {
+ compared++;
+ }
+ if (differs) {
+ changed++;
+ maskPixels[index] = nowColor;
+ } else {
+ maskPixels[index] = fade(nowColor);
+ }
+ }
+ }
+ changedShare = compared == 0L ? 0D : changed / (double) compared;
+ changes = mask;
+ changesKey = key;
+ return mask;
+ }
+
+ private String changesCaption() {
+ if (changedShare == 0D) {
+ return "Nothing on screen changed in the hotload";
+ }
+ double percent = changedShare * 100D;
+ String amount = percent < 0.1D ? "<0.1" : String.format(Locale.ROOT, percent < 10D ? "%.1f" : "%.0f", percent);
+ return amount + "% of the map on screen changed in the hotload";
+ }
+
+ private static int[] pixels(BufferedImage image) {
+ return ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
+ }
+
+ private static int fade(int color) {
+ int red = (int) (((color >> 16) & 0xFF) * (1D - UNCHANGED_FADE) + ((UNCHANGED_FADE_TARGET >> 16) & 0xFF) * UNCHANGED_FADE);
+ int green = (int) (((color >> 8) & 0xFF) * (1D - UNCHANGED_FADE) + ((UNCHANGED_FADE_TARGET >> 8) & 0xFF) * UNCHANGED_FADE);
+ int blue = (int) ((color & 0xFF) * (1D - UNCHANGED_FADE) + (UNCHANGED_FADE_TARGET & 0xFF) * UNCHANGED_FADE);
+ return (red << 16) | (green << 8) | blue;
+ }
+
+ private static void drawSideLabel(Graphics2D canvas, String text, int anchorX, double pushX) {
+ canvas.setFont(StudioTheme.boldFont());
+ int padding = StudioTheme.scale(8);
+ int width = canvas.getFontMetrics().stringWidth(text) + padding * 2;
+ int height = canvas.getFontMetrics().getHeight() + padding;
+ int x = (int) Math.round(anchorX - width * pushX);
+ int y = StudioTheme.scale(12);
+ StudioOverlay.drawCard(canvas, x, y, width, height);
+ canvas.setColor(StudioTheme.TEXT);
+ canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent());
+ }
+
+ private static void drawCaption(Graphics2D canvas, int width, String text) {
+ canvas.setFont(StudioTheme.font());
+ int padding = StudioTheme.scale(10);
+ int captionWidth = canvas.getFontMetrics().stringWidth(text) + padding * 2;
+ int height = canvas.getFontMetrics().getHeight() + padding;
+ int x = (width - captionWidth) / 2;
+ int y = StudioTheme.scale(12);
+ StudioOverlay.drawCard(canvas, x, y, captionWidth, height);
+ canvas.setColor(StudioTheme.TEXT);
+ canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent());
+ }
+
+ private record ChangesKey(long nowVersion, int offsetX, int offsetZ, int width, int height) {
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java
index d5a730f61..33ead7e0b 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java
@@ -20,11 +20,12 @@ package com.volmit.iris.core.gui;
import com.volmit.iris.Iris;
import com.volmit.iris.core.gui.components.IrisRenderer;
+import com.volmit.iris.core.gui.components.MapLabels;
+import com.volmit.iris.core.gui.components.MapStyle;
import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisDimension;
-import com.volmit.iris.engine.object.IrisRegion;
import com.volmit.iris.util.format.Form;
import javax.swing.JMenuItem;
@@ -33,6 +34,7 @@ import javax.swing.JPopupMenu;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import java.awt.Color;
+import java.awt.Cursor;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
@@ -73,6 +75,7 @@ final class MapCanvas extends JPanel {
private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D;
private static final double WHEEL_ZOOM_EXPONENT = 0.08D;
private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D;
+ private static final int LEGEND_REFRESH_MILLIS = 200;
private final UUID openerId;
private final Listener listener;
@@ -82,7 +85,9 @@ final class MapCanvas extends JPanel {
private final Timer hoverTimer;
private final Timer markerTimer;
private final Timer notificationTimer;
+ private final Timer legendTimer;
private final Map notifications = new LinkedHashMap<>();
+ private final HotloadComparison comparison = new HotloadComparison();
private Engine engine;
private IrisRenderer renderer;
@@ -90,13 +95,17 @@ final class MapCanvas extends JPanel {
private VisionRenderController.Frame renderFrame;
private BufferedImage tileLayer;
private boolean tileLayerStale = true;
+ private long tileLayerVersion;
+ private MapStyle style = MapStyle.PLAIN;
private RenderType currentType = RenderType.BIOME;
private String viewLabel = MapView.BIOMES.label();
private List players = List.of();
private List entities = List.of();
- private HoverInfo hoverInfo;
+ private QuickHover quickHover;
+ private MapHoverProbe.Details hoverDetails;
private Point cursor;
private Point dragOrigin;
+ private boolean draggingDivider;
private double centerX;
private double centerZ;
private double heightMaximum;
@@ -135,6 +144,7 @@ final class MapCanvas extends JPanel {
});
this.hoverTimer = singleShotTimer(90, this::requestHover);
+ this.legendTimer = singleShotTimer(LEGEND_REFRESH_MILLIS, this::publishLegend);
this.markerTimer = new Timer(650, event -> refreshMarkers());
this.markerTimer.setCoalesce(true);
this.notificationTimer = new Timer(200, event -> expireNotifications());
@@ -172,6 +182,18 @@ final class MapCanvas extends JPanel {
return follow;
}
+ MapStyle style() {
+ return style;
+ }
+
+ HotloadComparison.Mode comparisonMode() {
+ return comparison.hasBefore() ? comparison.mode() : HotloadComparison.Mode.OFF;
+ }
+
+ boolean hasBeforePicture() {
+ return comparison.hasBefore();
+ }
+
void start() {
refreshMarkers();
requestRender();
@@ -195,6 +217,10 @@ final class MapCanvas extends JPanel {
requestRender();
}
+ String legendTitle() {
+ return currentType == RenderType.REGION ? "Regions in view" : "Biomes in view";
+ }
+
// Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones.
void setActive(boolean nextActive) {
active = nextActive;
@@ -211,12 +237,117 @@ final class MapCanvas extends JPanel {
notifyUser("No active Iris engine");
return;
}
+ boolean captured = captureBeforePicture();
renderer = new IrisRenderer(engine);
overlay = new VisionOverlay(engine, openerId);
contentRevision++;
captureHeightRange();
requestRender();
- notifyUser("Refreshing");
+ notifyUser(captured && comparison.mode() == HotloadComparison.Mode.OFF
+ ? "Refreshing · press C to compare with before"
+ : "Refreshing");
+ listener.onTogglesChanged();
+ }
+
+ // Only a finished picture is worth comparing against; a half drawn one would show its gaps as changes.
+ private boolean captureBeforePicture() {
+ if (tileLayer == null || controller.progress(renderFrame).completion() < 1D) {
+ comparison.forget();
+ return false;
+ }
+ comparison.capture(currentTileLayer(), currentScene());
+ return true;
+ }
+
+ void toggleShading() {
+ applyStyle(style.withShading(!style.shading()));
+ notifyUser(style.shading() ? "Terrain shading on" : "Terrain shading off");
+ listener.onTogglesChanged();
+ }
+
+ void toggleRegionBorders() {
+ applyStyle(style.withRegionBorders(!style.regionBorders()));
+ notifyUser(style.regionBorders() ? "Region borders on" : "Region borders off");
+ listener.onTogglesChanged();
+ }
+
+ /**
+ * Highlights the biome or region, or clears the highlight when it is the one already highlighted.
+ */
+ void toggleHighlight(MapLabels.Label label) {
+ if (label == null) {
+ return;
+ }
+ MapStyle.Highlight current = style.highlight();
+ if (current != null && current.kind() == label.kind() && current.key().equals(label.key())) {
+ clearHighlight();
+ return;
+ }
+ applyStyle(style.withHighlight(new MapStyle.Highlight(label.kind(), label.key(), label.name())));
+ notifyUser("Highlighting " + label.name() + " · Esc clears");
+ }
+
+ void highlightUnderCursor() {
+ VisionRenderController.TilePixel pixel = cursor == null ? null : tilePixelAt(cursor);
+ if (pixel == null) {
+ notifyUser("Point at the map to highlight what is there");
+ return;
+ }
+ toggleHighlight(pixel.subject());
+ }
+
+ /**
+ * Esc steps back one layer of emphasis: the highlight first, then the comparison, then the help.
+ */
+ void dismissEmphasis() {
+ if (style.highlight() != null) {
+ clearHighlight();
+ } else if (comparisonMode() != HotloadComparison.Mode.OFF) {
+ showComparison(HotloadComparison.Mode.OFF);
+ } else if (help) {
+ toggleHelp();
+ }
+ }
+
+ private void clearHighlight() {
+ applyStyle(style.withHighlight(null));
+ notifyUser("Highlight cleared");
+ }
+
+ void cycleComparison() {
+ showComparison(comparisonMode().next());
+ }
+
+ void showComparison(HotloadComparison.Mode mode) {
+ if (!comparison.hasBefore()) {
+ notifyUser("Refresh or hotload the pack first, then compare with before");
+ listener.onTogglesChanged();
+ return;
+ }
+ comparison.setMode(mode);
+ notifyUser(switch (mode) {
+ case OFF -> "Comparison off";
+ case SWIPE -> "Drag the divider to compare before and now";
+ case CHANGES -> "Showing what changed in the hotload";
+ });
+ repaint();
+ listener.onTogglesChanged();
+ }
+
+ private void applyStyle(MapStyle next) {
+ boolean heightsWereNeeded = needsHeights();
+ style = next;
+ if (needsHeights() != heightsWereNeeded) {
+ requestRender();
+ } else {
+ tileLayerStale = true;
+ repaint();
+ }
+ scheduleLegendUpdate();
+ }
+
+ private boolean needsHeights() {
+ return style.shading() || currentType == RenderType.HEIGHT;
}
void toggleGrid() {
@@ -289,6 +420,7 @@ final class MapCanvas extends JPanel {
}
closed = true;
hoverTimer.stop();
+ legendTimer.stop();
markerTimer.stop();
notificationTimer.stop();
GuiHost.unregisterHotloadHook(hotloadHook);
@@ -308,6 +440,10 @@ final class MapCanvas extends JPanel {
public void mouseMoved(MouseEvent event) {
cursor = event.getPoint();
setDetailedHover(event.isShiftDown());
+ setCursor(comparison.isNearDivider(event.getX(), getWidth())
+ ? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
+ : Cursor.getDefaultCursor());
+ showQuickHover();
hoverTimer.restart();
refreshStatus();
}
@@ -315,6 +451,11 @@ final class MapCanvas extends JPanel {
@Override
public void mouseDragged(MouseEvent event) {
cursor = event.getPoint();
+ if (draggingDivider) {
+ comparison.moveDivider(event.getX(), getWidth());
+ repaint();
+ return;
+ }
if (dragOrigin == null) {
return;
}
@@ -322,7 +463,7 @@ final class MapCanvas extends JPanel {
centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel;
dragOrigin = event.getPoint();
stopFollowing();
- hoverInfo = null;
+ clearHover();
requestRender();
}
@@ -339,6 +480,8 @@ final class MapCanvas extends JPanel {
cursor = event.getPoint();
if (event.isPopupTrigger()) {
showContextMenu(event);
+ } else if (SwingUtilities.isLeftMouseButton(event) && comparison.isNearDivider(event.getX(), getWidth())) {
+ draggingDivider = true;
} else if (SwingUtilities.isLeftMouseButton(event)) {
dragOrigin = event.getPoint();
}
@@ -347,6 +490,7 @@ final class MapCanvas extends JPanel {
@Override
public void mouseReleased(MouseEvent event) {
dragOrigin = null;
+ draggingDivider = false;
if (event.isPopupTrigger()) {
showContextMenu(event);
}
@@ -375,7 +519,7 @@ final class MapCanvas extends JPanel {
@Override
public void mouseExited(MouseEvent event) {
cursor = null;
- hoverInfo = null;
+ clearHover();
hoverTimer.stop();
refreshStatus();
repaint();
@@ -391,6 +535,7 @@ final class MapCanvas extends JPanel {
int blockX = floorWorldCoordinate(screenToWorldX(point.x));
int blockZ = floorWorldCoordinate(screenToWorldZ(point.y));
JPopupMenu menu = new JPopupMenu();
+ addHighlightItems(menu, tilePixelAt(point));
menu.add(menuItem("Show palettes here", () -> showPalettesAt(point)));
menu.add(menuItem("Teleport here", () -> teleportTo(point)));
menu.add(menuItem(currentType == RenderType.REGION ? "Open region file" : "Open biome file", () -> openInEditor(point)));
@@ -399,6 +544,30 @@ final class MapCanvas extends JPanel {
menu.show(this, point.x, point.y);
}
+ private void addHighlightItems(JPopupMenu menu, VisionRenderController.TilePixel pixel) {
+ MapLabels.Label subject = pixel == null ? null : pixel.subject();
+ MapLabels.Label region = pixel == null ? null : pixel.region();
+ if (subject != null) {
+ menu.add(menuItem(highlightText(subject), () -> toggleHighlight(subject)));
+ }
+ if (region != null && subject != null && subject.kind() != MapLabels.Kind.REGION) {
+ menu.add(menuItem(highlightText(region), () -> toggleHighlight(region)));
+ }
+ if (style.highlight() != null) {
+ menu.add(menuItem("Clear highlight", this::clearHighlight));
+ }
+ if (menu.getComponentCount() > 0) {
+ menu.addSeparator();
+ }
+ }
+
+ private String highlightText(MapLabels.Label label) {
+ MapStyle.Highlight current = style.highlight();
+ boolean highlighted = current != null && current.kind() == label.kind() && current.key().equals(label.key());
+ String kind = label.kind() == MapLabels.Kind.REGION ? "region " : "";
+ return (highlighted ? "Stop highlighting " : "Highlight ") + kind + label.name();
+ }
+
private static JMenuItem menuItem(String text, Runnable action) {
JMenuItem item = new JMenuItem(text);
item.addActionListener(event -> action.run());
@@ -459,7 +628,17 @@ final class MapCanvas extends JPanel {
// The display server gets one finished picture per paint: handing it every tile separately made the map lag.
private void renderTiles(Graphics2D canvas) {
- canvas.drawImage(currentTileLayer(), 0, 0, null);
+ BufferedImage layer = currentTileLayer();
+ if (!comparison.isShowing()) {
+ canvas.drawImage(layer, 0, 0, null);
+ return;
+ }
+ boolean complete = controller.progress(renderFrame).completion() >= 1D;
+ comparison.paint(canvas, layer, tileLayerVersion, currentScene(), complete);
+ }
+
+ private HotloadComparison.Scene currentScene() {
+ return new HotloadComparison.Scene(currentType, style, blocksPerPixel, centerX, centerZ);
}
private BufferedImage currentTileLayer() {
@@ -478,6 +657,7 @@ final class MapCanvas extends JPanel {
layer.dispose();
}
tileLayerStale = false;
+ tileLayerVersion++;
}
return tileLayer;
}
@@ -498,9 +678,8 @@ final class MapCanvas extends JPanel {
int screenY = (int) Math.round(worldToScreenZ(worldZ));
int tileWidth = Math.max(1, (int) Math.round(worldToScreenX(worldX + frameTileSpan)) - screenX);
int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY);
- BufferedImage image = tile.image();
- if (image != null) {
- canvas.drawImage(image, screenX, screenY, tileWidth, tileHeight, null);
+ if (tile.tile() != null) {
+ canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null);
continue;
}
canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
@@ -509,7 +688,7 @@ final class MapCanvas extends JPanel {
}
}
- private static void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY,
+ private void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY,
int tileWidth, int tileHeight) {
if (fallback == null) {
return;
@@ -519,7 +698,7 @@ final class MapCanvas extends JPanel {
int top = screenY + (int) Math.round(piece.offsetZ() * tileHeight);
int right = screenX + (int) Math.round((piece.offsetX() + piece.size()) * tileWidth);
int bottom = screenY + (int) Math.round((piece.offsetZ() + piece.size()) * tileHeight);
- canvas.drawImage(piece.image(), left, top, right, bottom,
+ canvas.drawImage(piece.tile().image(style), left, top, right, bottom,
piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null);
}
}
@@ -651,30 +830,21 @@ final class MapCanvas extends JPanel {
}
private void renderHover(Graphics2D canvas) {
- HoverInfo info = hoverInfo;
- if (info == null) {
+ QuickHover quick = quickHover;
+ if (quick == null || draggingDivider) {
return;
}
- ArrayList lines = new ArrayList<>();
- lines.add(info.biomeName());
- lines.add(info.regionName());
- lines.add("Block " + ((int) info.worldX()) + ", " + ((int) info.worldZ()));
- if (detailedHover) {
- lines.add("Chunk " + ((int) info.worldX() >> 4) + ", " + ((int) info.worldZ() >> 4));
- lines.add("Region file " + ((int) info.worldX() >> 9) + ", " + ((int) info.worldZ() >> 9));
- lines.add("Key " + info.biomeKey());
- lines.add("File " + info.biomeFile());
- } else {
- lines.add("Hold Shift for more, double-click for palettes");
- }
- int screenX = (int) Math.round(worldToScreenX(info.worldX())) + StudioTheme.scale(16);
- int screenY = (int) Math.round(worldToScreenZ(info.worldZ()));
+ MapHoverProbe.Details details = hoverDetails;
+ List lines = MapHoverCard.lines(quick.subject(), quick.region(), details, currentType, detailedHover,
+ quick.worldX(), quick.worldZ());
+ int screenX = (int) Math.round(worldToScreenX(quick.worldX())) + StudioTheme.scale(16);
+ int screenY = (int) Math.round(worldToScreenZ(quick.worldZ()));
drawCard(canvas, screenX, screenY, 0D, 0D, lines);
}
private void renderHelp(Graphics2D canvas) {
List keys = List.of("Drag", "Wheel / + -", "\\", "Double-click", "Right-click", "Ctrl+Click", "Alt+Click",
- "Shift", "R", "F", "G", "E", "M", "1 to 0, P", "/");
+ "Shift", "H", "Esc", "S", "B", "C", "R", "F", "G", "E", "M", "1 to 0, P", "/");
List descriptions = List.of(
"Move the map",
"Zoom in and out",
@@ -684,6 +854,11 @@ final class MapCanvas extends JPanel {
"Teleport there",
"Open the biome file",
"More details while hovering",
+ "Highlight what is under the cursor",
+ "Clear the highlight",
+ "Terrain shading",
+ "Region borders",
+ "Compare with before the last hotload",
"Redraw the map",
"Follow your player",
"Chunk and region grid",
@@ -776,6 +951,28 @@ final class MapCanvas extends JPanel {
tileLayerStale = true;
repaint();
refreshStatus();
+ scheduleLegendUpdate();
+ if (quickHover != null && quickHover.subject() == null) {
+ showQuickHover();
+ }
+ }
+
+ // At most one count per interval however fast tiles arrive, so the legend stays live without costing frames.
+ private void scheduleLegendUpdate() {
+ if (!legendTimer.isRunning()) {
+ legendTimer.start();
+ }
+ }
+
+ private void publishLegend() {
+ if (closed || !active) {
+ return;
+ }
+ IrisRenderer legendRenderer = renderer;
+ RenderType type = currentType;
+ List shares = MapCensus.count(renderFrame, screenToWorldX(0D), screenToWorldZ(0D),
+ screenToWorldX(getWidth()), screenToWorldZ(getHeight()), label -> legendRenderer.legendColor(label, type));
+ listener.onLegendChanged(legendTitle(), shares, style.highlight());
}
private void requestRender() {
@@ -784,7 +981,7 @@ final class MapCanvas extends JPanel {
repaint();
return;
}
- hoverInfo = null;
+ clearHover();
VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec(
renderer,
currentType,
@@ -793,11 +990,13 @@ final class MapCanvas extends JPanel {
centerZ,
blocksPerPixel,
getWidth(),
- getHeight()
+ getHeight(),
+ needsHeights()
);
renderFrame = controller.request(spec);
repaint();
refreshStatus();
+ scheduleLegendUpdate();
}
private void refreshStatus() {
@@ -894,25 +1093,38 @@ final class MapCanvas extends JPanel {
double worldZ = screenToWorldZ(point.y);
controller.submitProbe(
frame,
- () -> sampleHover(probeEngine, worldX, worldZ),
- info -> {
- hoverInfo = info;
- repaint();
+ () -> MapHoverProbe.sample(probeEngine, worldX, worldZ),
+ details -> {
+ QuickHover quick = quickHover;
+ if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) {
+ hoverDetails = details;
+ repaint();
+ }
}
);
}
- private static HoverInfo sampleHover(Engine engine, double worldX, double worldZ) {
- IrisBiome biome = engine.getComplex().getBaseBiomeStream().get(worldX, worldZ);
- IrisRegion region = engine.getComplex().getRegionStream().get(worldX, worldZ);
- return new HoverInfo(
- worldX,
- worldZ,
- biome.getName(),
- region.getName(),
- biome.getLoadKey(),
- String.valueOf(biome.getLoadFile())
- );
+ // The drawn tiles already know the biome and region under the cursor, so the card follows the mouse without waiting.
+ private void showQuickHover() {
+ Point point = cursor;
+ if (point == null || dragOrigin != null) {
+ return;
+ }
+ double worldX = screenToWorldX(point.x);
+ double worldZ = screenToWorldZ(point.y);
+ VisionRenderController.TilePixel pixel = tilePixelAt(point);
+ quickHover = new QuickHover(worldX, worldZ, pixel == null ? null : pixel.subject(), pixel == null ? null : pixel.region());
+ repaint();
+ }
+
+ private void clearHover() {
+ quickHover = null;
+ hoverDetails = null;
+ }
+
+ private VisionRenderController.TilePixel tilePixelAt(Point point) {
+ VisionRenderController.Frame frame = renderFrame;
+ return frame == null ? null : frame.pixelAt(screenToWorldX(point.x), screenToWorldZ(point.y));
}
private void stopFollowing() {
@@ -963,7 +1175,7 @@ final class MapCanvas extends JPanel {
centerZ = next.centerZ();
blocksPerPixel = next.blocksPerPixel();
stopFollowing();
- hoverInfo = null;
+ clearHover();
requestRender();
}
@@ -1017,15 +1229,10 @@ final class MapCanvas extends JPanel {
void onTogglesChanged();
void onShowPalettesAt(double worldX, double worldZ);
+
+ void onLegendChanged(String title, List shares, MapStyle.Highlight highlight);
}
- private record HoverInfo(
- double worldX,
- double worldZ,
- String biomeName,
- String regionName,
- String biomeKey,
- String biomeFile
- ) {
+ private record QuickHover(double worldX, double worldZ, MapLabels.Label subject, MapLabels.Label region) {
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCensus.java b/core/src/main/java/com/volmit/iris/core/gui/MapCensus.java
new file mode 100644
index 000000000..7bba7aa39
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapCensus.java
@@ -0,0 +1,109 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.components.MapLabels;
+import com.volmit.iris.core.gui.components.MapTile;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.ToIntFunction;
+
+/**
+ * Counts how much of the visible map each biome or region covers, from the tiles already drawn.
+ */
+final class MapCensus {
+ private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
+
+ private MapCensus() {
+ }
+
+ static List count(VisionRenderController.Frame frame, double minimumX, double minimumZ, double maximumX,
+ double maximumZ, ToIntFunction colorOf) {
+ if (frame == null) {
+ return List.of();
+ }
+ double blocksPerPixel = frame.renderBlocksPerPixel();
+ int stride = countingStride(maximumX - minimumX, maximumZ - minimumZ, blocksPerPixel);
+ double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
+ Map counts = new HashMap<>();
+ MapLabels labels = null;
+ long counted = 0L;
+ for (VisionRenderController.VisibleTile visible : frame.tiles()) {
+ MapTile tile = visible.tile();
+ if (tile == null) {
+ continue;
+ }
+ labels = tile.labels();
+ double originX = visible.tileX() * tileSpan;
+ double originZ = visible.tileZ() * tileSpan;
+ int firstX = firstPixel(minimumX - originX, blocksPerPixel, stride, visible.tileX());
+ int firstZ = firstPixel(minimumZ - originZ, blocksPerPixel, stride, visible.tileZ());
+ int endX = endPixel(maximumX - originX, blocksPerPixel, tile.size());
+ int endZ = endPixel(maximumZ - originZ, blocksPerPixel, tile.size());
+ for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) {
+ for (int pixelX = firstX; pixelX < endX; pixelX += stride) {
+ counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
+ counted++;
+ }
+ }
+ }
+ return shares(counts, labels, counted, colorOf);
+ }
+
+ private static List shares(Map counts, MapLabels labels, long counted,
+ ToIntFunction colorOf) {
+ if (labels == null || counted == 0L) {
+ return List.of();
+ }
+ List shares = new ArrayList<>(counts.size());
+ for (Map.Entry entry : counts.entrySet()) {
+ MapLabels.Label label = labels.label(entry.getKey());
+ if (label != null) {
+ shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted));
+ }
+ }
+ shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name()));
+ return List.copyOf(shares);
+ }
+
+ // Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages.
+ private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) {
+ double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel);
+ return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS)));
+ }
+
+ // Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely.
+ private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) {
+ int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel));
+ long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first;
+ int misalignment = (int) Math.floorMod(globalPixel, (long) stride);
+ return misalignment == 0 ? first : first + stride - misalignment;
+ }
+
+ private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) {
+ return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel));
+ }
+
+ record Share(MapLabels.Label label, int color, double fraction) {
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java
new file mode 100644
index 000000000..e4cf8f135
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java
@@ -0,0 +1,112 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.components.MapLabels;
+import com.volmit.iris.core.gui.components.RenderType;
+import com.volmit.iris.core.loader.IrisRegistrant;
+import com.volmit.iris.engine.object.IrisBiome;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The text of the card that follows the cursor: what the drawn map shows there at once, and the rest once probed.
+ */
+final class MapHoverCard {
+ private MapHoverCard() {
+ }
+
+ /**
+ * @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
+ * @param details the probed facts about the spot, or null until they arrive
+ * @param detailed whether Shift is held, which adds keys and files
+ */
+ static List lines(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
+ RenderType type, boolean detailed, double worldX, double worldZ) {
+ List lines = new ArrayList<>();
+ lines.add(title(subject, details));
+ if (type != RenderType.REGION) {
+ String regionName = region != null ? region.name() : details != null ? details.region().getName() : null;
+ if (regionName != null) {
+ lines.add("Region " + regionName);
+ }
+ }
+ if (details != null) {
+ addTerrain(lines, details, type);
+ }
+ int blockX = MapCanvas.floorWorldCoordinate(worldX);
+ int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
+ lines.add("Block " + blockX + ", " + blockZ + " · chunk " + (blockX >> 4) + ", " + (blockZ >> 4));
+ if (!detailed) {
+ lines.add("Shift for keys and files · H highlights");
+ return lines;
+ }
+ addSource(lines, subject);
+ if (subject != null && subject.kind() != MapLabels.Kind.REGION) {
+ addSource(lines, region);
+ }
+ lines.add("Region file r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca");
+ return lines;
+ }
+
+ private static String title(MapLabels.Label subject, MapHoverProbe.Details details) {
+ if (subject != null) {
+ return subject.name();
+ }
+ if (details != null && details.surfaceBiome() != null) {
+ return details.surfaceBiome().getName();
+ }
+ return "Drawing…";
+ }
+
+ private static void addTerrain(List lines, MapHoverProbe.Details details, RenderType type) {
+ lines.add("Height " + Math.round(details.height()) + " · sea level " + Math.round(details.fluidHeight()));
+ String slope = "slope " + Math.round(details.slopeDegrees()) + "°";
+ lines.add(details.terrainKind() == null ? capitalize(slope) : details.terrainKind() + " · " + slope);
+ IrisBiome surface = details.surfaceBiome();
+ IrisBiome cave = details.caveBiome();
+ if (type == RenderType.CAVE_LAND && surface != null) {
+ lines.add("Surface biome " + surface.getName());
+ } else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) {
+ lines.add("Cave biome " + cave.getName());
+ }
+ if (details.vanillaBiome() != null) {
+ lines.add("Minecraft biome " + details.vanillaBiome().replace('_', ' '));
+ }
+ }
+
+ private static void addSource(List lines, MapLabels.Label label) {
+ if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) {
+ return;
+ }
+ String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome";
+ lines.add(kind + " key " + label.key());
+ File file = registrant.getLoadFile();
+ if (file != null) {
+ File folder = file.getParentFile();
+ lines.add(kind + " file " + (folder == null ? "" : folder.getName() + "/") + file.getName());
+ }
+ }
+
+ private static String capitalize(String text) {
+ return Character.toUpperCase(text.charAt(0)) + text.substring(1);
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java
new file mode 100644
index 000000000..36b0d1c65
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java
@@ -0,0 +1,90 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.engine.IrisComplex;
+import com.volmit.iris.engine.framework.Engine;
+import com.volmit.iris.engine.object.InferredType;
+import com.volmit.iris.engine.object.IrisBiome;
+import com.volmit.iris.engine.object.IrisDimension;
+import com.volmit.iris.engine.object.IrisRegion;
+import org.bukkit.block.Biome;
+
+import java.util.Locale;
+
+/**
+ * Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread.
+ */
+final class MapHoverProbe {
+ // IrisComplex measures slope as the height change over this many blocks.
+ private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D;
+
+ private MapHoverProbe() {
+ }
+
+ static Details sample(Engine engine, double worldX, double worldZ) {
+ IrisComplex complex = engine.getComplex();
+ IrisBiome surfaceBiome = complex.getBaseBiomeStream().get(worldX, worldZ);
+ IrisBiome caveBiome = complex.getCaveBiomeStream().get(worldX, worldZ);
+ IrisRegion region = complex.getRegionStream().get(worldX, worldZ);
+ InferredType terrainKind = complex.getBridgeStream().get(worldX, worldZ);
+ Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ);
+ double height = complex.getHeightStream().getDouble(worldX, worldZ);
+ double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
+ IrisDimension dimension = engine.getDimension();
+ return new Details(
+ worldX,
+ worldZ,
+ surfaceBiome,
+ caveBiome,
+ region,
+ terrainKindName(surfaceBiome, terrainKind),
+ vanillaBiome == null ? null : vanillaBiome.getKey().getKey(),
+ height,
+ dimension == null ? 0D : dimension.getFluidHeight(),
+ Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS))
+ );
+ }
+
+ // A shore biome sits on land the continent stream calls land or sea, so the biome's own type says it best.
+ private static String terrainKindName(IrisBiome surfaceBiome, InferredType continentKind) {
+ InferredType kind = surfaceBiome != null && surfaceBiome.getInferredType() != null
+ ? surfaceBiome.getInferredType()
+ : continentKind;
+ if (kind == null) {
+ return null;
+ }
+ String name = kind.name().toLowerCase(Locale.ROOT);
+ return Character.toUpperCase(name.charAt(0)) + name.substring(1);
+ }
+
+ record Details(
+ double worldX,
+ double worldZ,
+ IrisBiome surfaceBiome,
+ IrisBiome caveBiome,
+ IrisRegion region,
+ String terrainKind,
+ String vanillaBiome,
+ double height,
+ double fluidHeight,
+ double slopeDegrees
+ ) {
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java b/core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java
new file mode 100644
index 000000000..e3e8b10f8
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java
@@ -0,0 +1,204 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.components.MapStyle;
+
+import javax.swing.BorderFactory;
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.DefaultListModel;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.RenderingHints;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+/**
+ * The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map.
+ */
+final class MapLegendPanel extends JPanel {
+ private static final long serialVersionUID = 4471297416386034128L;
+ private static final int PANEL_WIDTH = 250;
+ private static final int ROW_HEIGHT = 26;
+ private static final int SWATCH_SIZE = 12;
+ private static final int SWATCH_GAP = 8;
+ private static final int ROW_PADDING = 10;
+ private static final int PERCENT_WIDTH = 42;
+ private static final String DRAWING_MESSAGE = "Drawing the map…";
+
+ private final JLabel title = new JLabel();
+ private final DefaultListModel rows = new DefaultListModel<>();
+ private final JList list = new JList<>(rows);
+ private MapStyle.Highlight highlight;
+
+ MapLegendPanel(Consumer onRowClicked) {
+ super(new BorderLayout());
+ setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
+ setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
+ setBackground(StudioTheme.PANEL_BACKGROUND);
+
+ title.setFont(StudioTheme.sectionFont());
+ title.setForeground(StudioTheme.MUTED_TEXT);
+ title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16));
+ add(title, BorderLayout.NORTH);
+
+ list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+ list.setFocusable(false);
+ list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
+ list.setCellRenderer(new ShareRenderer());
+ list.setBackground(StudioTheme.PANEL_BACKGROUND);
+ list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
+ list.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent event) {
+ MapCensus.Share share = shareAt(event.getPoint());
+ if (share != null) {
+ onRowClicked.accept(share);
+ }
+ }
+ });
+ add(StudioToolbar.scroll(list), BorderLayout.CENTER);
+
+ JLabel hint = new JLabel("Click to highlight · Esc clears");
+ hint.setFont(StudioTheme.smallFont());
+ hint.setForeground(StudioTheme.MUTED_TEXT);
+ hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16));
+ add(hint, BorderLayout.SOUTH);
+ }
+
+ void show(String subjectTitle, List shares, MapStyle.Highlight nextHighlight) {
+ title.setText(subjectTitle.toUpperCase(Locale.ROOT));
+ highlight = nextHighlight;
+ if (!sameRows(shares)) {
+ rows.clear();
+ rows.addAll(shares);
+ }
+ list.repaint();
+ }
+
+ private boolean sameRows(List shares) {
+ if (rows.size() != shares.size()) {
+ return false;
+ }
+ for (int index = 0; index < shares.size(); index++) {
+ if (!rows.get(index).equals(shares.get(index))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private MapCensus.Share shareAt(Point point) {
+ int index = list.locationToIndex(point);
+ if (index < 0) {
+ return null;
+ }
+ Rectangle bounds = list.getCellBounds(index, index);
+ return bounds != null && bounds.contains(point) ? rows.get(index) : null;
+ }
+
+ private boolean isHighlighted(MapCensus.Share share) {
+ return highlight != null
+ && highlight.kind() == share.label().kind()
+ && highlight.key().equals(share.label().key());
+ }
+
+ @Override
+ protected void paintChildren(Graphics graphics) {
+ super.paintChildren(graphics);
+ if (!rows.isEmpty()) {
+ return;
+ }
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ canvas.setFont(StudioTheme.font());
+ canvas.setColor(StudioTheme.MUTED_TEXT);
+ canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16),
+ title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent());
+ } finally {
+ canvas.dispose();
+ }
+ }
+
+ private final class ShareRenderer extends DefaultListCellRenderer {
+ private static final long serialVersionUID = -2350212934617052618L;
+ private Color swatch = Color.BLACK;
+ private String percent = "";
+
+ @Override
+ public Component getListCellRendererComponent(JList> owner, Object value, int index, boolean selected, boolean focused) {
+ MapCensus.Share share = (MapCensus.Share) value;
+ super.getListCellRendererComponent(owner, share.label().name(), index, isHighlighted(share), false);
+ setFont(StudioTheme.font());
+ setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH));
+ setToolTipText(share.label().key());
+ swatch = new Color(share.color());
+ percent = formatPercent(share.fraction());
+ return this;
+ }
+
+ @Override
+ protected void paintComponent(Graphics graphics) {
+ super.paintComponent(graphics);
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ int size = StudioTheme.scale(SWATCH_SIZE);
+ int swatchX = StudioTheme.scale(ROW_PADDING);
+ int swatchY = (getHeight() - size) / 2;
+ int arc = StudioTheme.scale(4);
+ canvas.setColor(swatch);
+ canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc);
+ canvas.setColor(StudioTheme.CONTROL_BORDER);
+ canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc);
+ canvas.setFont(StudioTheme.smallFont());
+ canvas.setColor(StudioTheme.SECONDARY_TEXT);
+ int textWidth = canvas.getFontMetrics().stringWidth(percent);
+ int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
+ canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline);
+ } finally {
+ canvas.dispose();
+ }
+ }
+ }
+
+ private static String formatPercent(double fraction) {
+ double percent = fraction * 100D;
+ if (percent < 0.1D) {
+ return "<0.1%";
+ }
+ return percent < 10D
+ ? String.format(Locale.ROOT, "%.1f%%", percent)
+ : String.format(Locale.ROOT, "%.0f%%", percent);
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
index 445176988..d23b7b6f8 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
@@ -18,6 +18,8 @@
package com.volmit.iris.core.gui;
+import com.volmit.iris.core.gui.components.MapStyle;
+
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
@@ -30,6 +32,7 @@ import javax.swing.JSplitPane;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
+import javax.swing.SwingConstants;
import javax.swing.text.JTextComponent;
import java.awt.BorderLayout;
import java.awt.CardLayout;
@@ -68,6 +71,13 @@ public final class MapWindow implements MapCanvas.Listener {
private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)");
private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)");
private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
+ private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)");
+ private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)");
+ private final JToggleButton swipeToggle = StudioToolbar.toggle("Before / after",
+ "Split the map between before and after the last hotload or refresh (C)");
+ private final JToggleButton changesToggle = StudioToolbar.toggle("Changes",
+ "Fade out everything the last hotload or refresh left the same (C twice)");
+ private final MapLegendPanel legendPanel;
private NoiseExplorerGUI paletteExplorer;
private MapView currentView;
@@ -80,6 +90,7 @@ public final class MapWindow implements MapCanvas.Listener {
this.canvas = new MapCanvas(start.engine(), start.playerId(), this);
this.viewList = new MapViewList(this::showView);
this.viewListScroll = StudioToolbar.scroll(viewList);
+ this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
canvas.centerOn(start.centerX(), start.centerZ());
buildFrame();
}
@@ -136,9 +147,21 @@ public final class MapWindow implements MapCanvas.Listener {
mapControls.add(gridToggle);
mapControls.add(entitiesToggle);
mapControls.add(followToggle);
+ mapControls.add(toolbarSeparator());
+ mapControls.add(shadingToggle);
+ mapControls.add(bordersToggle);
+ mapControls.add(toolbarSeparator());
+ mapControls.add(swipeToggle);
+ mapControls.add(changesToggle);
gridToggle.addActionListener(event -> canvas.toggleGrid());
entitiesToggle.addActionListener(event -> canvas.toggleEntities());
followToggle.addActionListener(event -> canvas.toggleFollow());
+ shadingToggle.addActionListener(event -> canvas.toggleShading());
+ bordersToggle.addActionListener(event -> canvas.toggleRegionBorders());
+ swipeToggle.addActionListener(event -> canvas.showComparison(
+ swipeToggle.isSelected() ? HotloadComparison.Mode.SWIPE : HotloadComparison.Mode.OFF));
+ changesToggle.addActionListener(event -> canvas.showComparison(
+ changesToggle.isSelected() ? HotloadComparison.Mode.CHANGES : HotloadComparison.Mode.OFF));
mapControls.forEach(toolbar::add);
paletteControls.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetPaletteView));
@@ -151,6 +174,12 @@ public final class MapWindow implements MapCanvas.Listener {
return toolbar;
}
+ private static JComponent toolbarSeparator() {
+ JToolBar.Separator separator = new JToolBar.Separator();
+ separator.setOrientation(SwingConstants.VERTICAL);
+ return separator;
+ }
+
private void showView(MapView view) {
if (view == null || view == currentView) {
return;
@@ -179,6 +208,7 @@ public final class MapWindow implements MapCanvas.Listener {
setControlsVisible(paletteControls, false);
useSidebar(viewListScroll);
inspectorSlot.removeAll();
+ inspectorSlot.add(legendPanel, BorderLayout.CENTER);
showCard(MAP_CARD);
canvas.requestFocusInWindow();
}
@@ -265,6 +295,15 @@ public final class MapWindow implements MapCanvas.Listener {
gridToggle.setSelected(canvas.isGridVisible());
entitiesToggle.setSelected(canvas.areEntitiesVisible());
followToggle.setSelected(canvas.isFollowing());
+ shadingToggle.setSelected(canvas.style().shading());
+ bordersToggle.setSelected(canvas.style().regionBorders());
+ swipeToggle.setSelected(canvas.comparisonMode() == HotloadComparison.Mode.SWIPE);
+ changesToggle.setSelected(canvas.comparisonMode() == HotloadComparison.Mode.CHANGES);
+ }
+
+ @Override
+ public void onLegendChanged(String title, List shares, MapStyle.Highlight highlight) {
+ legendPanel.show(title, shares, highlight);
}
private void resetPaletteView() {
@@ -297,6 +336,11 @@ public final class MapWindow implements MapCanvas.Listener {
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
+ bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor);
+ bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis);
+ bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading);
+ bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders);
+ bindMap(rootPane, KeyEvent.VK_C, 0, "compare", canvas::cycleComparison);
bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
diff --git a/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java b/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java
index 0699331c2..f21a3f2f0 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java
@@ -20,10 +20,11 @@ package com.volmit.iris.core.gui;
import com.volmit.iris.Iris;
import com.volmit.iris.core.gui.components.IrisRenderer;
+import com.volmit.iris.core.gui.components.MapLabels;
+import com.volmit.iris.core.gui.components.MapTile;
import com.volmit.iris.core.gui.components.RenderType;
import java.awt.EventQueue;
-import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -60,7 +61,7 @@ final class VisionRenderController implements AutoCloseable {
private static final int MAXIMUM_VISIBLE_TILES = 32_768;
private static final int PROBE_QUEUE_CAPACITY = 1;
private static final int COARSER_FALLBACK_LEVELS = 6;
- private static final long CACHE_BYTES = 128L * 1024L * 1024L;
+ private static final long CACHE_BYTES = 256L * 1024L * 1024L;
private final Runnable listener;
private final int workerCount;
@@ -119,7 +120,7 @@ final class VisionRenderController implements AutoCloseable {
List tiles = visibleTiles(spec, renderScale);
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
for (VisibleTile tile : tiles) {
- tile.setImage(cache.get(tile.key()));
+ tile.setTile(cachedTileServing(tile.key()));
}
currentFrame = frame;
probeSequence.incrementAndGet();
@@ -133,17 +134,30 @@ final class VisionRenderController implements AutoCloseable {
* A tile from another zoom level covering the given one, to draw while the real tile renders.
*/
Fallback fallback(Frame frame, VisibleTile tile) {
+ MapTile withoutHeights = cache.get(tile.key().withHeights(false));
+ if (withoutHeights != null) {
+ return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
+ }
Fallback coarser = coarserFallback(frame, tile);
return coarser != null ? coarser : finerFallback(frame, tile);
}
+ // A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing.
+ private MapTile cachedTileServing(TileKey key) {
+ MapTile exact = cache.get(key);
+ if (exact != null || key.heights()) {
+ return exact;
+ }
+ return cache.get(key.withHeights(true));
+ }
+
Progress progress(Frame frame) {
if (frame == null) {
return new Progress(0, 0, 0);
}
int ready = 0;
for (VisibleTile tile : frame.tiles()) {
- if (tile.image() != null) {
+ if (tile.tile() != null) {
ready++;
}
}
@@ -198,7 +212,7 @@ final class VisionRenderController implements AutoCloseable {
for (long tileX = minimumX; tileX <= maximumX; tileX++) {
double deltaX = tileX + 0.5D - centerTileX;
double deltaZ = tileZ + 0.5D - centerTileZ;
- TileKey key = new TileKey(spec.contentRevision(), spec.type(), renderScale, tileX, tileZ);
+ TileKey key = new TileKey(spec.contentRevision(), spec.type(), spec.heights(), renderScale, tileX, tileZ);
tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ));
}
}
@@ -283,19 +297,20 @@ final class VisionRenderController implements AutoCloseable {
TileKey key = job.key();
try {
double tileSpan = TILE_PIXELS * key.blocksPerPixel();
- BufferedImage image = job.renderer().renderStudio(
+ MapTile tile = job.renderer().renderTile(
key.tileX() * tileSpan,
key.tileZ() * tileSpan,
- tileSpan,
+ key.blocksPerPixel(),
TILE_PIXELS,
key.type(),
+ key.heights(),
() -> !isWanted(key)
);
- cache.put(key, image);
+ cache.put(key, tile);
Frame frame = currentFrame;
VisibleTile visible = frame == null ? null : frame.tile(key);
if (visible != null) {
- visible.setImage(image);
+ visible.setTile(tile);
publish();
}
} catch (CancellationException ignored) {
@@ -322,7 +337,8 @@ final class VisionRenderController implements AutoCloseable {
}
long parentX = Math.floorDiv(key.tileX(), scale);
long parentZ = Math.floorDiv(key.tileZ(), scale);
- BufferedImage parent = cache.get(new TileKey(key.contentRevision(), key.type(), parentScale, parentX, parentZ));
+ MapTile parent = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), parentScale, parentX,
+ parentZ));
if (parent != null) {
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
@@ -341,7 +357,7 @@ final class VisionRenderController implements AutoCloseable {
List pieces = new ArrayList<>(4);
for (int childZ = 0; childZ < 2; childZ++) {
for (int childX = 0; childX < 2; childX++) {
- BufferedImage child = cache.get(new TileKey(key.contentRevision(), key.type(), childScale,
+ MapTile child = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), childScale,
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
if (child != null) {
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
@@ -410,7 +426,8 @@ final class VisionRenderController implements AutoCloseable {
double centerZ,
double blocksPerPixel,
int width,
- int height
+ int height,
+ boolean heights
) {
RenderSpec {
Objects.requireNonNull(renderer, "renderer");
@@ -468,11 +485,33 @@ final class VisionRenderController implements AutoCloseable {
return tilesByKey.get(key);
}
+ /**
+ * The tile covering a world position together with the pixel of it there, or null when that tile is not drawn yet.
+ */
+ TilePixel pixelAt(double worldX, double worldZ) {
+ double tileSpan = TILE_PIXELS * renderBlocksPerPixel;
+ long tileX = (long) Math.floor(worldX / tileSpan);
+ long tileZ = (long) Math.floor(worldZ / tileSpan);
+ VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.type(), spec.heights(),
+ renderBlocksPerPixel, tileX, tileZ));
+ MapTile tile = visible == null ? null : visible.tile();
+ if (tile == null) {
+ return null;
+ }
+ int pixelX = pixelWithin(worldX - tileX * tileSpan);
+ int pixelZ = pixelWithin(worldZ - tileZ * tileSpan);
+ return new TilePixel(tile, pixelX, pixelZ);
+ }
+
+ private int pixelWithin(double offsetBlocks) {
+ return Math.max(0, Math.min(TILE_PIXELS - 1, (int) Math.floor(offsetBlocks / renderBlocksPerPixel)));
+ }
+
// Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
private VisibleTile nextMissing(Set inFlight, boolean advance) {
for (int index = nextCandidate; index < tiles.size(); index++) {
VisibleTile tile = tiles.get(index);
- if (tile.image() == null && !inFlight.contains(tile.key())) {
+ if (tile.tile() == null && !inFlight.contains(tile.key())) {
if (advance) {
nextCandidate = index + 1;
}
@@ -489,7 +528,7 @@ final class VisionRenderController implements AutoCloseable {
static final class VisibleTile {
private final TileKey key;
private final double distanceSquared;
- private volatile BufferedImage image;
+ private volatile MapTile tile;
private VisibleTile(TileKey key, double distanceSquared) {
this.key = key;
@@ -512,22 +551,39 @@ final class VisionRenderController implements AutoCloseable {
return distanceSquared;
}
- BufferedImage image() {
- return image;
+ MapTile tile() {
+ return tile;
}
- private void setImage(BufferedImage image) {
- this.image = image;
+ private void setTile(MapTile tile) {
+ this.tile = tile;
}
}
- record TileKey(long contentRevision, RenderType type, double blocksPerPixel, long tileX, long tileZ) {
+ /**
+ * @param heights whether the tile also carries terrain heights, for shading
+ */
+ record TileKey(long contentRevision, RenderType type, boolean heights, double blocksPerPixel, long tileX, long tileZ) {
TileKey {
Objects.requireNonNull(type, "type");
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Vision cache scale must be finite and positive");
}
}
+
+ TileKey withHeights(boolean nextHeights) {
+ return new TileKey(contentRevision, type, nextHeights, blocksPerPixel, tileX, tileZ);
+ }
+ }
+
+ record TilePixel(MapTile tile, int pixelX, int pixelZ) {
+ MapLabels.Label subject() {
+ return tile.labels().label(tile.subjectAt(pixelX, pixelZ));
+ }
+
+ MapLabels.Label region() {
+ return tile.labels().label(tile.regionAt(pixelX, pixelZ));
+ }
}
/**
@@ -536,7 +592,7 @@ final class VisionRenderController implements AutoCloseable {
record Fallback(List pieces) {
}
- record FallbackPiece(BufferedImage image, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
+ record FallbackPiece(MapTile tile, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
double size) {
}
@@ -562,18 +618,18 @@ final class VisionRenderController implements AutoCloseable {
this.entries = new LinkedHashMap<>(128, 0.75F, true);
}
- synchronized BufferedImage get(TileKey key) {
+ synchronized MapTile get(TileKey key) {
CacheEntry entry = entries.get(key);
- return entry == null ? null : entry.image();
+ return entry == null ? null : entry.tile();
}
- synchronized void put(TileKey key, BufferedImage image) {
- long imageBytes = (long) image.getWidth() * image.getHeight() * Integer.BYTES;
- CacheEntry previous = entries.put(key, new CacheEntry(image, imageBytes));
+ synchronized void put(TileKey key, MapTile tile) {
+ long tileBytes = tile.bytes();
+ CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes));
if (previous != null) {
bytes -= previous.bytes();
}
- bytes += imageBytes;
+ bytes += tileBytes;
while (bytes > maximumBytes && !entries.isEmpty()) {
Iterator> iterator = entries.entrySet().iterator();
Map.Entry eldest = iterator.next();
@@ -588,6 +644,6 @@ final class VisionRenderController implements AutoCloseable {
}
}
- private record CacheEntry(BufferedImage image, long bytes) {
+ private record CacheEntry(MapTile tile, long bytes) {
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java
index ab8e7ba48..6749fed99 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java
@@ -18,7 +18,6 @@
package com.volmit.iris.core.gui.components;
-import com.volmit.iris.core.gui.GuiHost;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
@@ -28,121 +27,87 @@ import com.volmit.iris.engine.object.IrisRegion;
import com.volmit.iris.util.stream.ProceduralStream;
import java.awt.Color;
-import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CancellationException;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
+/**
+ * Draws map tiles for one generation of the pack: a hotload needs a new renderer, which also starts new labels.
+ */
public final class IrisRenderer {
- private static final int BLUE = new Color(45, 91, 156).getRGB();
- private static final int YELLOW = new Color(211, 164, 67).getRGB();
- private static final int GREEN = new Color(78, 137, 83).getRGB();
- private static final int DEEP_WATER = new Color(20, 48, 92).getRGB();
- private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB();
- private static final int LOWLAND = new Color(78, 128, 76).getRGB();
- private static final int HIGHLAND = new Color(151, 139, 92).getRGB();
- private static final int ROCK = new Color(126, 119, 112).getRGB();
- private static final int SNOW = new Color(226, 230, 232).getRGB();
+ private static final int BLUE = rgb(45, 91, 156);
+ private static final int YELLOW = rgb(211, 164, 67);
+ private static final int GREEN = rgb(78, 137, 83);
+ private static final int DEEP_WATER = rgb(20, 48, 92);
+ private static final int SHALLOW_WATER = rgb(50, 112, 154);
+ private static final int LOWLAND = rgb(78, 128, 76);
+ private static final int HIGHLAND = rgb(151, 139, 92);
+ private static final int ROCK = rgb(126, 119, 112);
+ private static final int SNOW = rgb(226, 230, 232);
+ private static final int UNKNOWN_SUBJECT = rgb(90, 92, 98);
+ private static final int LARGEST_SAMPLE_BLOCK_PIXELS = 16;
+ private static final double LARGEST_SAMPLE_BLOCK_BLOCKS = 64D;
- private final Engine renderer;
+ private final Engine engine;
+ private final MapLabels labels = new MapLabels();
+ private final ConcurrentHashMap labelColors = new ConcurrentHashMap<>();
- public IrisRenderer(Engine renderer) {
- this.renderer = Objects.requireNonNull(renderer, "renderer");
+ public IrisRenderer(Engine engine) {
+ this.engine = Objects.requireNonNull(engine, "engine");
}
- public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
- return render(sx, sz, size, resolution, currentType, () -> false);
+ public MapLabels labels() {
+ return labels;
}
- public BufferedImage render(
- double sx,
- double sz,
- double size,
- int resolution,
- RenderType currentType,
+ /**
+ * Renders the square of {@code size} pixels whose top left pixel samples the given world position.
+ *
+ * @param withHeights also sample terrain heights for shading; the height view always has them
+ */
+ public MapTile renderTile(
+ double startX,
+ double startZ,
+ double blocksPerPixel,
+ int size,
+ RenderType type,
+ boolean withHeights,
BooleanSupplier cancelled
) {
- return render(sx, sz, size, resolution, currentType, cancelled, false);
- }
-
- public BufferedImage renderStudio(
- double sx,
- double sz,
- double size,
- int resolution,
- RenderType currentType,
- BooleanSupplier cancelled
- ) {
- return render(sx, sz, size, resolution, currentType, cancelled, true);
- }
-
- private BufferedImage render(
- double sx,
- double sz,
- double size,
- int resolution,
- RenderType currentType,
- BooleanSupplier cancelled,
- boolean studio
- ) {
- if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D) {
- throw new IllegalArgumentException("Vision render coordinates and size must be finite");
+ if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
+ throw new IllegalArgumentException("Map tile coordinates and scale must be finite");
}
- if (resolution < 1) {
- throw new IllegalArgumentException("Vision render resolution must be positive");
+ if (size < 1) {
+ throw new IllegalArgumentException("Map tile size must be positive");
}
- Objects.requireNonNull(currentType, "currentType");
+ Objects.requireNonNull(type, "type");
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
- int[] pixels = renderPixels(sx, sz, size, resolution, currentType, cancelled, studio);
- return GuiHost.uncachedImage(pixels, resolution, resolution);
+ TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled);
+ int[] identities = sampleIdentities(grid, type);
+ double[] heights = withHeights || type == RenderType.HEIGHT ? sampleHeights(grid) : null;
+ short[] subjects = new short[identities.length];
+ short[] regions = new short[identities.length];
+ for (int index = 0; index < identities.length; index++) {
+ subjects[index] = subjectOf(identities[index]);
+ regions[index] = regionOf(identities[index]);
+ }
+ int[] colors = tileColors(size, type, subjects, heights);
+ MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
+ return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, subjects, regions, heights);
}
- private int[] renderPixels(
- double sx,
- double sz,
- double size,
- int resolution,
- RenderType currentType,
- BooleanSupplier cancelled,
- boolean studio
- ) {
- int[] pixels = new int[resolution * resolution];
- double step = size / resolution;
- if (studio && currentType == RenderType.HEIGHT) {
- renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled);
- return pixels;
- }
- PixelShader shader = shader(currentType, studio);
- if (studio && adaptiveStudioType(currentType)) {
- renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
- return pixels;
- }
- int groupSize = sampleGroup(step, resolution);
-
- for (int groupZ = 0; groupZ < resolution; groupZ += groupSize) {
- checkCancelled(cancelled);
- int maximumZ = Math.min(resolution, groupZ + groupSize);
- for (int groupX = 0; groupX < resolution; groupX += groupSize) {
- checkCancelled(cancelled);
- int maximumX = Math.min(resolution, groupX + groupSize);
- for (int pixelZ = groupZ; pixelZ < maximumZ; pixelZ++) {
- double z = sz + step * pixelZ;
- int row = pixelZ * resolution;
- for (int pixelX = groupX; pixelX < maximumX; pixelX++) {
- checkCancelled(cancelled);
- double x = sx + step * pixelX;
- pixels[row + pixelX] = shader.color(x, z);
- }
- }
- }
- }
-
- return pixels;
+ /**
+ * The colour a biome or region has on the map in the given view, for legends.
+ */
+ public int legendColor(MapLabels.Label label, RenderType type) {
+ RenderType colorType = type == RenderType.HEIGHT ? RenderType.BIOME : type;
+ return labelColor(label.id(), colorType);
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {
@@ -164,151 +129,96 @@ public final class IrisRenderer {
return blend(ROCK, SNOW, (land - 0.78D) / 0.22D);
}
- static int sampleGroup(double step, int resolution) {
- double absoluteStep = Math.abs(step);
- if (!Double.isFinite(absoluteStep) || absoluteStep <= 0D) {
- return 1;
- }
- return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep)));
+ private int[] sampleIdentities(TileGrid grid, RenderType type) {
+ IdentitySampler sampler = new IdentitySampler(grid, identityShader(type));
+ sampler.renderAll(sampleBlockPixels(grid.step()));
+ return sampler.identities;
}
- private PixelShader shader(RenderType currentType, boolean studio) {
- IrisComplex complex = renderer.getComplex();
- return switch (currentType) {
- case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader(
- studio ? complex.getBaseBiomeStream() : complex.getTrueBiomeStream(), currentType);
- case BIOME_LAND -> biomeShader(complex.getLandBiomeStream(), currentType);
- case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType);
- case REGION -> regionShader(complex, currentType);
- case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType);
- case HEIGHT -> heightShader(complex.getHeightStream());
- case CONTINENT -> studio
- ? continentShader(complex.getBaseBiomeStream())
- : this::continentColor;
- };
+ private double[] sampleHeights(TileGrid grid) {
+ return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights();
}
- private static boolean adaptiveStudioType(RenderType type) {
- return switch (type) {
- case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND,
- CONTINENT -> true;
- case HEIGHT -> false;
- };
+ // Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes.
+ private static int sampleBlockPixels(double blocksPerPixel) {
+ int largestPixels = (int) Math.floor(LARGEST_SAMPLE_BLOCK_BLOCKS / blocksPerPixel);
+ return Integer.highestOneBit(Math.max(1, Math.min(LARGEST_SAMPLE_BLOCK_PIXELS, largestPixels)));
}
- private static void renderAdaptiveAtlas(
- int[] pixels,
- int resolution,
- double startX,
- double startZ,
- double step,
- PixelShader shader,
- BooleanSupplier cancelled
- ) {
- int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
- int blockPixels = Integer.highestOneBit(maximumPixels);
- AdaptiveSampler sampler = new AdaptiveSampler(pixels, resolution, startX, startZ, step, shader, cancelled);
- for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
- int height = Math.min(blockPixels, resolution - pixelZ);
- for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
- sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height);
- }
- }
- }
-
- private static void renderHeightAtlas(
- int[] pixels,
- int resolution,
- double startX,
- double startZ,
- double step,
- Engine engine,
- BooleanSupplier cancelled
- ) {
+ private IdentityShader identityShader(RenderType type) {
IrisComplex complex = engine.getComplex();
+ ProceduralStream regionStream = complex.getRegionStream();
+ LabelLookup lookup = new LabelLookup(labels);
+ if (type == RenderType.REGION) {
+ return (double x, double z) -> {
+ short region = lookup.idOf(regionStream.get(x, z));
+ return identity(region, region);
+ };
+ }
+ ProceduralStream biomeStream = subjectBiomeStream(complex, type);
+ return (double x, double z) -> identity(lookup.idOf(biomeStream.get(x, z)), lookup.idOf(regionStream.get(x, z)));
+ }
+
+ private static ProceduralStream subjectBiomeStream(IrisComplex complex, RenderType type) {
+ return switch (type) {
+ case BIOME_LAND -> complex.getLandBiomeStream();
+ case BIOME_SEA -> complex.getSeaBiomeStream();
+ case CAVE_LAND -> complex.getCaveBiomeStream();
+ case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, HEIGHT, CONTINENT, REGION -> complex.getBaseBiomeStream();
+ };
+ }
+
+ private int[] tileColors(int size, RenderType type, short[] subjects, double[] heights) {
+ int[] colors = new int[size * size];
+ double maximumHeight = engine.getHeight();
+ double fluidHeight = fluidHeight();
+ short previousSubject = MapLabels.NONE;
+ int previousColor = UNKNOWN_SUBJECT;
+ for (int pixelZ = 0; pixelZ < size; pixelZ++) {
+ for (int pixelX = 0; pixelX < size; pixelX++) {
+ int gridIndex = (pixelZ + 1) * (size + 2) + pixelX + 1;
+ if (type == RenderType.HEIGHT) {
+ colors[pixelZ * size + pixelX] = heightColor(heights[gridIndex], maximumHeight, fluidHeight);
+ continue;
+ }
+ short subject = subjects[gridIndex];
+ if (subject != previousSubject) {
+ previousSubject = subject;
+ previousColor = labelColor(subject, type);
+ }
+ colors[pixelZ * size + pixelX] = previousColor;
+ }
+ }
+ return colors;
+ }
+
+ private double fluidHeight() {
IrisDimension dimension = engine.getDimension();
- double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight();
- int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
- int blockPixels = Integer.highestOneBit(maximumPixels);
- HeightSampler sampler = new HeightSampler(
- pixels,
- resolution,
- startX,
- startZ,
- step,
- complex.getHeightStream(),
- engine.getHeight(),
- fluidHeight,
- cancelled
- );
- for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
- int height = Math.min(blockPixels, resolution - pixelZ);
- for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
- sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height);
- }
+ return dimension == null ? 0D : dimension.getFluidHeight();
+ }
+
+ private int labelColor(short id, RenderType type) {
+ MapLabels.Label label = labels.label(id);
+ if (label == null) {
+ return UNKNOWN_SUBJECT;
}
+ return labelColors.computeIfAbsent(new LabelColorKey(type, id), key -> subjectColor(label.subject(), type));
}
- private PixelShader biomeShader(ProceduralStream stream, RenderType currentType) {
- IdentityHashMap colors = new IdentityHashMap<>();
- return (double x, double z) -> {
- IrisBiome biome = stream.get(x, z);
- Integer color = colors.get(biome);
- if (color == null) {
- color = biome.getColor(renderer, currentType).getRGB();
- colors.put(biome, color);
- }
- return color;
- };
- }
-
- private PixelShader regionShader(IrisComplex complex, RenderType currentType) {
- ProceduralStream stream = complex.getRegionStream();
- IdentityHashMap colors = new IdentityHashMap<>();
- return (double x, double z) -> {
- IrisRegion region = stream.get(x, z);
- Integer color = colors.get(region);
- if (color == null) {
- color = region.getColor(complex, currentType).getRGB();
- colors.put(region, color);
- }
- return color;
- };
- }
-
- private PixelShader heightShader(ProceduralStream stream) {
- double maximumHeight = renderer.getHeight();
- IrisDimension dimension = renderer.getDimension();
- double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight();
- return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight);
- }
-
- private int continentColor(double x, double z) {
- IrisBiome biome = renderer.getBiome(
- (int) Math.round(x),
- renderer.getMaxHeight() - 1,
- (int) Math.round(z)
- );
- return continentColor(biome);
- }
-
- private PixelShader continentShader(ProceduralStream stream) {
- IdentityHashMap colors = new IdentityHashMap<>();
- return (double x, double z) -> {
- IrisBiome biome = stream.get(x, z);
- Integer color = colors.get(biome);
- if (color == null) {
- color = continentColor(biome);
- colors.put(biome, color);
- }
- return color;
- };
- }
-
- private int continentColor(IrisBiome biome) {
- if (biome == null) {
- return GREEN;
+ private int subjectColor(Object subject, RenderType type) {
+ if (type == RenderType.CONTINENT && subject instanceof IrisBiome biome) {
+ return continentColor(biome);
}
+ if (subject instanceof IrisRegion region) {
+ return region.getColor(engine.getComplex(), type).getRGB() & 0xFFFFFF;
+ }
+ if (subject instanceof IrisBiome biome) {
+ return biome.getColor(engine, type).getRGB() & 0xFFFFFF;
+ }
+ return UNKNOWN_SUBJECT;
+ }
+
+ private static int continentColor(IrisBiome biome) {
List generators = biome.getGenerators();
if (generators.isEmpty()) {
return GREEN;
@@ -323,6 +233,22 @@ public final class IrisRenderer {
return GREEN;
}
+ private static int identity(short subject, short region) {
+ return (subject << 16) | (region & 0xFFFF);
+ }
+
+ private static short subjectOf(int identity) {
+ return (short) (identity >> 16);
+ }
+
+ private static short regionOf(int identity) {
+ return (short) identity;
+ }
+
+ private static int rgb(int red, int green, int blue) {
+ return new Color(red, green, blue).getRGB() & 0xFFFFFF;
+ }
+
private static int blend(int first, int second, double progress) {
double bounded = clamp(progress, 0D, 1D);
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded);
@@ -337,262 +263,208 @@ public final class IrisRenderer {
private static void checkCancelled(BooleanSupplier cancelled) {
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
- throw new CancellationException("Vision render cancelled");
+ throw new CancellationException("Map tile render cancelled");
}
}
@FunctionalInterface
- private interface PixelShader {
- int color(double x, double z);
+ private interface IdentityShader {
+ int identity(double x, double z);
}
- private static final class AdaptiveSampler {
- private final int[] pixels;
- private final boolean[] sampled;
- private final int resolution;
- private final double startX;
- private final double startZ;
- private final double step;
- private final PixelShader shader;
- private final BooleanSupplier cancelled;
+ private record LabelColorKey(RenderType type, short id) {
+ }
- private AdaptiveSampler(
- int[] pixels,
- int resolution,
- double startX,
- double startZ,
- double step,
- PixelShader shader,
- BooleanSupplier cancelled
- ) {
- this.pixels = pixels;
- this.sampled = new boolean[pixels.length];
- this.resolution = resolution;
- this.startX = startX;
- this.startZ = startZ;
- this.step = step;
- this.shader = shader;
- this.cancelled = cancelled;
+ /**
+ * The square of world positions one tile samples, including the one pixel ring borrowed from its neighbours.
+ */
+ private record TileGrid(int size, double startX, double startZ, double step, BooleanSupplier cancelled) {
+ double worldX(int gridX) {
+ return startX + gridX * step;
}
- private void render(int pixelX, int pixelZ, int width, int height) {
- checkCancelled(cancelled);
+ double worldZ(int gridZ) {
+ return startZ + gridZ * step;
+ }
+ }
+
+ /**
+ * Labels the biomes and regions one tile meets, asking the shared labels only for ones this tile has not seen yet.
+ */
+ private static final class LabelLookup {
+ private final MapLabels labels;
+ private final IdentityHashMap