mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-20 07:24:16 +00:00
Make the map explain what it shows
Highlight a biome or region (legend click, right-click, or H under the cursor) to fade everything else and outline it; Esc clears it. A live legend beside the map lists the biomes or regions in view with their share of the screen. The hover card now answers at once from the drawn tiles and then adds height, sea level, land or sea, slope, cave biome and the Minecraft biome; Shift adds keys and files. After a refresh or hotload, C compares with the map from before: a draggable before/now divider, or a view that fades out everything that stayed the same and says how much changed. Shading (S) adds hillshade relief to every view and Borders (B) outlines the regions. Tiles now remember what each pixel shows, so all of this restyles without rendering again. Model used: Claude Opus 5.5
This commit is contained in:
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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) {
|
||||
}
|
||||
}
|
||||
@@ -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<String, Long> 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<GuiMarker> players = List.of();
|
||||
private List<GuiMarker> 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<String> 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<String> 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<String> 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<String> 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<MapCensus.Share> 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<MapCensus.Share> 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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Share> count(VisionRenderController.Frame frame, double minimumX, double minimumZ, double maximumX,
|
||||
double maximumZ, ToIntFunction<MapLabels.Label> 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<Short, long[]> 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<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
|
||||
ToIntFunction<MapLabels.Label> colorOf) {
|
||||
if (labels == null || counted == 0L) {
|
||||
return List.of();
|
||||
}
|
||||
List<Share> shares = new ArrayList<>(counts.size());
|
||||
for (Map.Entry<Short, long[]> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String> lines(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
|
||||
RenderType type, boolean detailed, double worldX, double worldZ) {
|
||||
List<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<MapCensus.Share> rows = new DefaultListModel<>();
|
||||
private final JList<MapCensus.Share> list = new JList<>(rows);
|
||||
private MapStyle.Highlight highlight;
|
||||
|
||||
MapLegendPanel(Consumer<MapCensus.Share> 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<MapCensus.Share> 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<MapCensus.Share> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<MapCensus.Share> 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);
|
||||
|
||||
@@ -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<VisibleTile> 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<FallbackPiece> 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<TileKey> 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<FallbackPiece> 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<Map.Entry<TileKey, CacheEntry>> iterator = entries.entrySet().iterator();
|
||||
Map.Entry<TileKey, CacheEntry> 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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LabelColorKey, Integer> 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<IrisRegion> 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<IrisBiome> 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<IrisBiome> 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<IrisBiome> stream, RenderType currentType) {
|
||||
IdentityHashMap<IrisBiome, Integer> 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<IrisRegion> stream = complex.getRegionStream();
|
||||
IdentityHashMap<IrisRegion, Integer> 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<Double> 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<IrisBiome> stream) {
|
||||
IdentityHashMap<IrisBiome, Integer> 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<IrisBiomeGeneratorLink> 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<Object, Short> seen = new IdentityHashMap<>();
|
||||
|
||||
private LabelLookup(MapLabels labels) {
|
||||
this.labels = labels;
|
||||
}
|
||||
|
||||
short idOf(IrisBiome biome) {
|
||||
if (biome == null) {
|
||||
return MapLabels.NONE;
|
||||
}
|
||||
return seen.computeIfAbsent(biome, subject -> labels.idOf(biome));
|
||||
}
|
||||
|
||||
short idOf(IrisRegion region) {
|
||||
if (region == null) {
|
||||
return MapLabels.NONE;
|
||||
}
|
||||
return seen.computeIfAbsent(region, subject -> labels.idOf(region));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples the biome and region of every grid cell in squares, filling a square without sampling its inside when
|
||||
* its corners and centre agree, and splitting it into quarters otherwise.
|
||||
*/
|
||||
private static final class IdentitySampler {
|
||||
private final TileGrid grid;
|
||||
private final IdentityShader shader;
|
||||
private final int[] identities;
|
||||
private final boolean[] sampled;
|
||||
|
||||
private IdentitySampler(TileGrid grid, IdentityShader shader) {
|
||||
this.grid = grid;
|
||||
this.shader = shader;
|
||||
this.identities = new int[grid.size() * grid.size()];
|
||||
this.sampled = new boolean[identities.length];
|
||||
}
|
||||
|
||||
void renderAll(int blockPixels) {
|
||||
int size = grid.size();
|
||||
for (int gridZ = 0; gridZ < size; gridZ += blockPixels) {
|
||||
int height = Math.min(blockPixels, size - gridZ);
|
||||
for (int gridX = 0; gridX < size; gridX += blockPixels) {
|
||||
render(gridX, gridZ, Math.min(blockPixels, size - gridX), height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void render(int gridX, int gridZ, int width, int height) {
|
||||
checkCancelled(grid.cancelled());
|
||||
if (width == 1 && height == 1) {
|
||||
sample(pixelX, pixelZ);
|
||||
sample(gridX, gridZ);
|
||||
return;
|
||||
}
|
||||
int maximumX = pixelX + width - 1;
|
||||
int maximumZ = pixelZ + height - 1;
|
||||
int centerX = pixelX + width / 2;
|
||||
int centerZ = pixelZ + height / 2;
|
||||
int color = sample(pixelX, pixelZ);
|
||||
if (sample(maximumX, pixelZ) == color
|
||||
&& sample(pixelX, maximumZ) == color
|
||||
&& sample(maximumX, maximumZ) == color
|
||||
&& sample(centerX, centerZ) == color) {
|
||||
fill(pixelX, pixelZ, width, height, color);
|
||||
if (fillIfUniform(gridX, gridZ, width, height)) {
|
||||
return;
|
||||
}
|
||||
int leftWidth = Math.max(1, width / 2);
|
||||
int rightWidth = width - leftWidth;
|
||||
int topHeight = Math.max(1, height / 2);
|
||||
int bottomHeight = height - topHeight;
|
||||
render(pixelX, pixelZ, leftWidth, topHeight);
|
||||
render(gridX, gridZ, leftWidth, topHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(pixelX + leftWidth, pixelZ, rightWidth, topHeight);
|
||||
render(gridX + leftWidth, gridZ, rightWidth, topHeight);
|
||||
}
|
||||
if (bottomHeight > 0) {
|
||||
render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
|
||||
render(gridX, gridZ + topHeight, leftWidth, bottomHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
|
||||
render(gridX + leftWidth, gridZ + topHeight, rightWidth, bottomHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int sample(int pixelX, int pixelZ) {
|
||||
int index = pixelZ * resolution + pixelX;
|
||||
if (!sampled[index]) {
|
||||
pixels[index] = shader.color(startX + pixelX * step, startZ + pixelZ * step);
|
||||
sampled[index] = true;
|
||||
private boolean fillIfUniform(int gridX, int gridZ, int width, int height) {
|
||||
int maximumX = gridX + width - 1;
|
||||
int maximumZ = gridZ + height - 1;
|
||||
int identity = sample(gridX, gridZ);
|
||||
boolean uniform = sample(maximumX, gridZ) == identity
|
||||
&& sample(gridX, maximumZ) == identity
|
||||
&& sample(maximumX, maximumZ) == identity
|
||||
&& sample(gridX + width / 2, gridZ + height / 2) == identity;
|
||||
if (!uniform) {
|
||||
return false;
|
||||
}
|
||||
return pixels[index];
|
||||
}
|
||||
|
||||
private void fill(int pixelX, int pixelZ, int width, int height, int color) {
|
||||
for (int row = pixelZ; row < pixelZ + height; row++) {
|
||||
int start = row * resolution + pixelX;
|
||||
Arrays.fill(pixels, start, start + width, color);
|
||||
for (int row = gridZ; row <= maximumZ; row++) {
|
||||
int start = row * grid.size() + gridX;
|
||||
Arrays.fill(identities, start, start + width, identity);
|
||||
Arrays.fill(sampled, start, start + width, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int sample(int gridX, int gridZ) {
|
||||
int index = gridZ * grid.size() + gridX;
|
||||
if (!sampled[index]) {
|
||||
identities[index] = shader.identity(grid.worldX(gridX), grid.worldZ(gridZ));
|
||||
sampled[index] = true;
|
||||
}
|
||||
return identities[index];
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HeightSampler {
|
||||
private static final double MAXIMUM_INTERPOLATION_ERROR = 1.5D;
|
||||
/**
|
||||
* Terrain heights from samples on a coarse lattice, smoothly interpolated in between. Iris heights are costly to
|
||||
* sample, and shading turns the steps of cheaper shortcuts into visible creases, so a smooth curve through fewer
|
||||
* exact samples looks better and draws an order of magnitude faster than sampling every pixel. The lattice lines
|
||||
* up across tiles, so slopes continue seamlessly from one tile into the next.
|
||||
*/
|
||||
private static final class HeightLattice {
|
||||
private static final int LATTICE_PIXELS = 4;
|
||||
private static final int LATTICE_MARGIN = 2;
|
||||
|
||||
private final int[] pixels;
|
||||
private final double[] heights;
|
||||
private final boolean[] sampled;
|
||||
private final int resolution;
|
||||
private final double startX;
|
||||
private final double startZ;
|
||||
private final double step;
|
||||
private final TileGrid grid;
|
||||
private final ProceduralStream<Double> stream;
|
||||
private final double maximumHeight;
|
||||
private final double fluidHeight;
|
||||
private final BooleanSupplier cancelled;
|
||||
private final int pointsPerSide;
|
||||
private final double[] points;
|
||||
|
||||
private HeightSampler(
|
||||
int[] pixels,
|
||||
int resolution,
|
||||
double startX,
|
||||
double startZ,
|
||||
double step,
|
||||
ProceduralStream<Double> stream,
|
||||
double maximumHeight,
|
||||
double fluidHeight,
|
||||
BooleanSupplier cancelled
|
||||
) {
|
||||
this.pixels = pixels;
|
||||
this.heights = new double[pixels.length];
|
||||
this.sampled = new boolean[pixels.length];
|
||||
this.resolution = resolution;
|
||||
this.startX = startX;
|
||||
this.startZ = startZ;
|
||||
this.step = step;
|
||||
private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
|
||||
this.grid = grid;
|
||||
this.stream = stream;
|
||||
this.maximumHeight = maximumHeight;
|
||||
this.fluidHeight = fluidHeight;
|
||||
this.cancelled = cancelled;
|
||||
this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
|
||||
this.points = new double[pointsPerSide * pointsPerSide];
|
||||
}
|
||||
|
||||
private void render(int pixelX, int pixelZ, int width, int height) {
|
||||
checkCancelled(cancelled);
|
||||
if (width == 1 && height == 1) {
|
||||
pixels[pixelZ * resolution + pixelX] = heightColor(
|
||||
sample(pixelX, pixelZ), maximumHeight, fluidHeight);
|
||||
return;
|
||||
double[] interpolatedHeights() {
|
||||
samplePoints();
|
||||
int size = grid.size();
|
||||
double[] heights = new double[size * size];
|
||||
for (int gridZ = 0; gridZ < size; gridZ++) {
|
||||
for (int gridX = 0; gridX < size; gridX++) {
|
||||
heights[gridZ * size + gridX] = heightAt(gridX - 1, gridZ - 1);
|
||||
}
|
||||
}
|
||||
int maximumX = pixelX + width - 1;
|
||||
int maximumZ = pixelZ + height - 1;
|
||||
int centerX = pixelX + width / 2;
|
||||
int centerZ = pixelZ + height / 2;
|
||||
double topLeft = sample(pixelX, pixelZ);
|
||||
double topRight = sample(maximumX, pixelZ);
|
||||
double bottomLeft = sample(pixelX, maximumZ);
|
||||
double bottomRight = sample(maximumX, maximumZ);
|
||||
if (matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
|
||||
centerX, pixelZ)
|
||||
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
|
||||
pixelX, centerZ)
|
||||
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
|
||||
maximumX, centerZ)
|
||||
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
|
||||
centerX, maximumZ)
|
||||
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
|
||||
centerX, centerZ)) {
|
||||
fillPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight);
|
||||
return;
|
||||
}
|
||||
int leftWidth = Math.max(1, width / 2);
|
||||
int rightWidth = width - leftWidth;
|
||||
int topHeight = Math.max(1, height / 2);
|
||||
int bottomHeight = height - topHeight;
|
||||
render(pixelX, pixelZ, leftWidth, topHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(pixelX + leftWidth, pixelZ, rightWidth, topHeight);
|
||||
}
|
||||
if (bottomHeight > 0) {
|
||||
render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
|
||||
return heights;
|
||||
}
|
||||
|
||||
// Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach.
|
||||
private void samplePoints() {
|
||||
double latticeStep = grid.step() * LATTICE_PIXELS;
|
||||
double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
|
||||
double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
|
||||
for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
|
||||
checkCancelled(grid.cancelled());
|
||||
for (int pointX = 0; pointX < pointsPerSide; pointX++) {
|
||||
points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep,
|
||||
firstZ + pointZ * latticeStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesPlane(
|
||||
int pixelX,
|
||||
int pixelZ,
|
||||
int width,
|
||||
int height,
|
||||
double topLeft,
|
||||
double topRight,
|
||||
double bottomLeft,
|
||||
double bottomRight,
|
||||
int sampleX,
|
||||
int sampleZ
|
||||
) {
|
||||
double predicted = interpolate(
|
||||
pixelX,
|
||||
pixelZ,
|
||||
width,
|
||||
height,
|
||||
topLeft,
|
||||
topRight,
|
||||
bottomLeft,
|
||||
bottomRight,
|
||||
sampleX,
|
||||
sampleZ
|
||||
);
|
||||
return Math.abs(sample(sampleX, sampleZ) - predicted) <= MAXIMUM_INTERPOLATION_ERROR;
|
||||
}
|
||||
|
||||
private void fillPlane(
|
||||
int pixelX,
|
||||
int pixelZ,
|
||||
int width,
|
||||
int height,
|
||||
double topLeft,
|
||||
double topRight,
|
||||
double bottomLeft,
|
||||
double bottomRight
|
||||
) {
|
||||
for (int row = pixelZ; row < pixelZ + height; row++) {
|
||||
int offset = row * resolution;
|
||||
for (int column = pixelX; column < pixelX + width; column++) {
|
||||
double value = interpolate(
|
||||
pixelX,
|
||||
pixelZ,
|
||||
width,
|
||||
height,
|
||||
topLeft,
|
||||
topRight,
|
||||
bottomLeft,
|
||||
bottomRight,
|
||||
column,
|
||||
row
|
||||
);
|
||||
pixels[offset + column] = heightColor(value, maximumHeight, fluidHeight);
|
||||
}
|
||||
private double heightAt(int pixelX, int pixelZ) {
|
||||
int cellX = Math.floorDiv(pixelX, LATTICE_PIXELS);
|
||||
int cellZ = Math.floorDiv(pixelZ, LATTICE_PIXELS);
|
||||
double fractionX = (pixelX - cellX * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
|
||||
double fractionZ = (pixelZ - cellZ * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
|
||||
int baseX = cellX + LATTICE_MARGIN;
|
||||
int baseZ = cellZ + LATTICE_MARGIN;
|
||||
double[] rows = new double[4];
|
||||
for (int row = 0; row < 4; row++) {
|
||||
int offset = (baseZ - 1 + row) * pointsPerSide + baseX - 1;
|
||||
rows[row] = catmullRom(points[offset], points[offset + 1], points[offset + 2], points[offset + 3], fractionX);
|
||||
}
|
||||
return catmullRom(rows[0], rows[1], rows[2], rows[3], fractionZ);
|
||||
}
|
||||
|
||||
private double sample(int pixelX, int pixelZ) {
|
||||
int index = pixelZ * resolution + pixelX;
|
||||
if (!sampled[index]) {
|
||||
heights[index] = stream.getDouble(startX + pixelX * step, startZ + pixelZ * step);
|
||||
sampled[index] = true;
|
||||
}
|
||||
return heights[index];
|
||||
}
|
||||
|
||||
private static double interpolate(
|
||||
int pixelX,
|
||||
int pixelZ,
|
||||
int width,
|
||||
int height,
|
||||
double topLeft,
|
||||
double topRight,
|
||||
double bottomLeft,
|
||||
double bottomRight,
|
||||
int sampleX,
|
||||
int sampleZ
|
||||
) {
|
||||
double x = width <= 1 ? 0D : (sampleX - pixelX) / (double) (width - 1);
|
||||
double z = height <= 1 ? 0D : (sampleZ - pixelZ) / (double) (height - 1);
|
||||
double top = topLeft + (topRight - topLeft) * x;
|
||||
double bottom = bottomLeft + (bottomRight - bottomLeft) * x;
|
||||
return top + (bottom - top) * z;
|
||||
private static double catmullRom(double before, double start, double end, double after, double fraction) {
|
||||
double squared = fraction * fraction;
|
||||
double cubed = squared * fraction;
|
||||
return 0.5D * (2D * start
|
||||
+ (end - before) * fraction
|
||||
+ (2D * before - 5D * start + 4D * end - after) * squared
|
||||
+ (3D * start - before - 3D * end + after) * cubed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui.components;
|
||||
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Numbers every biome and region the map has drawn, so a tile can remember what each of its pixels shows in two bytes.
|
||||
* One set of labels belongs to one generation of the pack: a hotload starts a new one.
|
||||
*/
|
||||
public final class MapLabels {
|
||||
public static final short NONE = -1;
|
||||
|
||||
private final IdentityHashMap<Object, Short> idsBySubject = new IdentityHashMap<>();
|
||||
private final Map<String, Short> idsByKey = new HashMap<>();
|
||||
private final List<Label> labels = new ArrayList<>();
|
||||
|
||||
synchronized short idOf(IrisBiome biome) {
|
||||
return idOf(biome, Kind.BIOME, biome.getLoadKey(), biome.getName());
|
||||
}
|
||||
|
||||
synchronized short idOf(IrisRegion region) {
|
||||
return idOf(region, Kind.REGION, region.getLoadKey(), region.getName());
|
||||
}
|
||||
|
||||
public synchronized Label label(short id) {
|
||||
return id < 0 || id >= labels.size() ? null : labels.get(id);
|
||||
}
|
||||
|
||||
public synchronized short idOf(Kind kind, String key) {
|
||||
Short id = idsByKey.get(kind.name() + ':' + key);
|
||||
return id == null ? NONE : id;
|
||||
}
|
||||
|
||||
private short idOf(Object subject, Kind kind, String key, String name) {
|
||||
Short existing = idsBySubject.get(subject);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
if (labels.size() >= Short.MAX_VALUE) {
|
||||
return NONE;
|
||||
}
|
||||
short id = (short) labels.size();
|
||||
labels.add(new Label(id, kind, key, name, subject));
|
||||
idsBySubject.put(subject, id);
|
||||
idsByKey.putIfAbsent(kind.name() + ':' + key, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
public enum Kind {
|
||||
BIOME,
|
||||
REGION
|
||||
}
|
||||
|
||||
public record Label(short id, Kind kind, String key, String name, Object subject) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui.components;
|
||||
|
||||
/**
|
||||
* How map tiles are drawn on top of their plain colours: terrain shading, region borders and one highlighted biome or region.
|
||||
*
|
||||
* @param highlight the biome or region to keep bright while everything else dims, or null to show all
|
||||
*/
|
||||
public record MapStyle(boolean shading, boolean regionBorders, Highlight highlight) {
|
||||
public static final MapStyle PLAIN = new MapStyle(false, false, null);
|
||||
|
||||
public MapStyle withShading(boolean nextShading) {
|
||||
return new MapStyle(nextShading, regionBorders, highlight);
|
||||
}
|
||||
|
||||
public MapStyle withRegionBorders(boolean nextRegionBorders) {
|
||||
return new MapStyle(shading, nextRegionBorders, highlight);
|
||||
}
|
||||
|
||||
public MapStyle withHighlight(Highlight nextHighlight) {
|
||||
return new MapStyle(shading, regionBorders, nextHighlight);
|
||||
}
|
||||
|
||||
public record Highlight(MapLabels.Kind kind, String key, String name) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui.components;
|
||||
|
||||
import com.volmit.iris.core.gui.GuiHost;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
/**
|
||||
* One rendered square of the map. Besides its colours it remembers which biome or region and which region every pixel
|
||||
* shows, and optionally the terrain height, so highlighting, borders, shading and the legend never need a new render.
|
||||
* The label and height grids have a one pixel border taken from the neighbouring tiles, so edges and slopes line up
|
||||
* across tile boundaries.
|
||||
*/
|
||||
public final class MapTile {
|
||||
private static final double SHADING_EXAGGERATION = 1.2D;
|
||||
private static final double LIGHT_X = -1D;
|
||||
private static final double LIGHT_Y = 1.5D;
|
||||
private static final double LIGHT_Z = -1D;
|
||||
private static final double LIGHT_LENGTH = Math.sqrt(LIGHT_X * LIGHT_X + LIGHT_Y * LIGHT_Y + LIGHT_Z * LIGHT_Z);
|
||||
private static final double FLAT_LIGHT = LIGHT_Y / LIGHT_LENGTH;
|
||||
private static final double DARKEST_SHADE = 0.55D;
|
||||
private static final double BRIGHTEST_SHADE = 1.25D;
|
||||
private static final int DIM_TARGET = 0x1E1F22;
|
||||
private static final double DIM_AMOUNT = 0.72D;
|
||||
private static final double DIM_DESATURATION = 0.6D;
|
||||
private static final int HIGHLIGHT_EDGE = 0xFFFFFF;
|
||||
private static final int REGION_BORDER = 0x101114;
|
||||
private static final double REGION_BORDER_STRENGTH = 0.6D;
|
||||
|
||||
private final int size;
|
||||
private final double blocksPerPixel;
|
||||
private final MapLabels labels;
|
||||
private final MapLabels.Kind subjectKind;
|
||||
private final int[] colors;
|
||||
private final short[] subjects;
|
||||
private final short[] regions;
|
||||
private final double[] heights;
|
||||
private MapStyle styledFor;
|
||||
private BufferedImage styledImage;
|
||||
|
||||
MapTile(int size, double blocksPerPixel, MapLabels labels, MapLabels.Kind subjectKind, int[] colors, short[] subjects,
|
||||
short[] regions, double[] heights) {
|
||||
this.size = size;
|
||||
this.blocksPerPixel = blocksPerPixel;
|
||||
this.labels = labels;
|
||||
this.subjectKind = subjectKind;
|
||||
this.colors = colors;
|
||||
this.subjects = subjects;
|
||||
this.regions = regions;
|
||||
this.heights = heights;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public MapLabels labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pixels name biomes or, in the region view, regions.
|
||||
*/
|
||||
public MapLabels.Kind subjectKind() {
|
||||
return subjectKind;
|
||||
}
|
||||
|
||||
public boolean hasHeights() {
|
||||
return heights != null;
|
||||
}
|
||||
|
||||
public short subjectAt(int pixelX, int pixelZ) {
|
||||
return subjects[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
public short regionAt(int pixelX, int pixelZ) {
|
||||
return regions[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
public long bytes() {
|
||||
long styledBytes = (long) size * size * Integer.BYTES;
|
||||
long gridCells = (long) (size + 2) * (size + 2);
|
||||
long heightBytes = heights == null ? 0L : gridCells * Double.BYTES;
|
||||
return (long) colors.length * Integer.BYTES + gridCells * Short.BYTES * 2L + heightBytes + styledBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile as it looks in the given style; the last style's picture is kept, so redrawing an unchanged map is free.
|
||||
*/
|
||||
public synchronized BufferedImage image(MapStyle style) {
|
||||
if (styledImage == null || !style.equals(styledFor)) {
|
||||
styledImage = GuiHost.uncachedImage(styledPixels(style), size, size);
|
||||
styledFor = style;
|
||||
}
|
||||
return styledImage;
|
||||
}
|
||||
|
||||
private int[] styledPixels(MapStyle style) {
|
||||
int[] pixels = new int[size * size];
|
||||
boolean shading = style.shading() && heights != null;
|
||||
short[] highlightGrid = highlightGrid(style.highlight());
|
||||
short highlighted = highlightGrid == null
|
||||
? MapLabels.NONE
|
||||
: labels.idOf(style.highlight().kind(), style.highlight().key());
|
||||
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
|
||||
for (int pixelX = 0; pixelX < size; pixelX++) {
|
||||
int color = colors[pixelZ * size + pixelX];
|
||||
if (shading) {
|
||||
color = shade(color, pixelX, pixelZ);
|
||||
}
|
||||
if (highlightGrid != null) {
|
||||
color = highlight(color, highlightGrid, pixelX, pixelZ, highlighted);
|
||||
}
|
||||
if (style.regionBorders() && isRegionEdge(pixelX, pixelZ)) {
|
||||
color = blend(color, REGION_BORDER, REGION_BORDER_STRENGTH);
|
||||
}
|
||||
pixels[pixelZ * size + pixelX] = color;
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
// Lit from the north west like a relief map, so slopes read as hills and valleys at a glance.
|
||||
private int shade(int color, int pixelX, int pixelZ) {
|
||||
double slopeX = (height(pixelX + 1, pixelZ) - height(pixelX - 1, pixelZ)) / (2D * blocksPerPixel);
|
||||
double slopeZ = (height(pixelX, pixelZ + 1) - height(pixelX, pixelZ - 1)) / (2D * blocksPerPixel);
|
||||
double normalX = -slopeX * SHADING_EXAGGERATION;
|
||||
double normalZ = -slopeZ * SHADING_EXAGGERATION;
|
||||
double normalLength = Math.sqrt(normalX * normalX + 1D + normalZ * normalZ);
|
||||
double light = (normalX * LIGHT_X + LIGHT_Y + normalZ * LIGHT_Z) / (normalLength * LIGHT_LENGTH);
|
||||
double factor = Math.max(DARKEST_SHADE, Math.min(BRIGHTEST_SHADE, light / FLAT_LIGHT));
|
||||
return scale(color, factor);
|
||||
}
|
||||
|
||||
// Every view knows its regions, so a region can be highlighted in a biome view too.
|
||||
private short[] highlightGrid(MapStyle.Highlight highlight) {
|
||||
if (highlight == null) {
|
||||
return null;
|
||||
}
|
||||
if (highlight.kind() == subjectKind) {
|
||||
return subjects;
|
||||
}
|
||||
return highlight.kind() == MapLabels.Kind.REGION ? regions : null;
|
||||
}
|
||||
|
||||
private int highlight(int color, short[] grid, int pixelX, int pixelZ, short highlighted) {
|
||||
if (grid[gridIndex(pixelX, pixelZ)] != highlighted) {
|
||||
return dim(color);
|
||||
}
|
||||
boolean edge = grid[gridIndex(pixelX + 1, pixelZ)] != highlighted
|
||||
|| grid[gridIndex(pixelX - 1, pixelZ)] != highlighted
|
||||
|| grid[gridIndex(pixelX, pixelZ + 1)] != highlighted
|
||||
|| grid[gridIndex(pixelX, pixelZ - 1)] != highlighted;
|
||||
return edge ? HIGHLIGHT_EDGE : color;
|
||||
}
|
||||
|
||||
private boolean isRegionEdge(int pixelX, int pixelZ) {
|
||||
short region = regionAt(pixelX, pixelZ);
|
||||
return regions[gridIndex(pixelX + 1, pixelZ)] != region || regions[gridIndex(pixelX, pixelZ + 1)] != region;
|
||||
}
|
||||
|
||||
private double height(int pixelX, int pixelZ) {
|
||||
return heights[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
// Pixel coordinates run from -1 to size, the outer ring being the neighbouring tiles' edge pixels.
|
||||
private int gridIndex(int pixelX, int pixelZ) {
|
||||
return (pixelZ + 1) * (size + 2) + pixelX + 1;
|
||||
}
|
||||
|
||||
private static int dim(int color) {
|
||||
int red = (color >> 16) & 0xFF;
|
||||
int green = (color >> 8) & 0xFF;
|
||||
int blue = color & 0xFF;
|
||||
int gray = (red + green + blue) / 3;
|
||||
int desaturated = blend(color, (gray << 16) | (gray << 8) | gray, DIM_DESATURATION);
|
||||
return blend(desaturated, DIM_TARGET, DIM_AMOUNT);
|
||||
}
|
||||
|
||||
private static int scale(int color, double factor) {
|
||||
int red = Math.min(255, (int) (((color >> 16) & 0xFF) * factor));
|
||||
int green = Math.min(255, (int) (((color >> 8) & 0xFF) * factor));
|
||||
int blue = Math.min(255, (int) ((color & 0xFF) * factor));
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
static int blend(int first, int second, double amount) {
|
||||
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - amount) + ((second >> 16) & 0xFF) * amount);
|
||||
int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - amount) + ((second >> 8) & 0xFF) * amount);
|
||||
int blue = (int) Math.round((first & 0xFF) * (1D - amount) + (second & 0xFF) * amount);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user