diff --git a/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java b/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java
index 18d2c6237..217da715f 100644
--- a/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java
+++ b/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java
@@ -21,9 +21,9 @@ package com.volmit.iris.core.commands;
import com.volmit.iris.Iris;
import com.volmit.iris.core.IrisSettings;
import com.volmit.iris.core.gui.GuiHost;
+import com.volmit.iris.core.gui.MapWindow;
import com.volmit.iris.core.gui.NoiseExplorerGUI;
import com.volmit.iris.core.gui.PaletteExplorerStart;
-import com.volmit.iris.core.gui.VisionGUI;
import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.core.project.IrisProject;
import com.volmit.iris.core.service.ConversionSVC;
@@ -263,8 +263,8 @@ public class CommandStudio implements DecreeExecutor {
}
Location location = player().getLocation();
IrisBiome surfaceBiome = engine.getSurfaceBiome(location.getBlockX(), location.getBlockZ());
- sender().sendMessage(C.GREEN + "Opening Palette Explorer!");
- NoiseExplorerGUI.launchPalettes(new PaletteExplorerStart(
+ sender().sendMessage(C.GREEN + "Opening the map on its palettes!");
+ MapWindow.openPalettes(new PaletteExplorerStart(
engine,
location.getWorld().getUID(),
player().getUniqueId(),
@@ -468,7 +468,15 @@ public class CommandStudio implements DecreeExecutor {
return;
}
- VisionGUI.launch(IrisToolbelt.access(world).getEngine(), sender().isPlayer() ? player().getUniqueId() : null);
+ Location center = sender().isPlayer() && player().getWorld().equals(world) ? player().getLocation() : null;
+ MapWindow.openMap(new PaletteExplorerStart(
+ IrisToolbelt.access(world).getEngine(),
+ world.getUID(),
+ sender().isPlayer() ? player().getUniqueId() : null,
+ null,
+ center == null ? 0D : center.getX(),
+ center == null ? 0D : center.getZ()
+ ));
sender().sendMessage(C.GREEN + "Opening map!");
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ExplorerStyle.java b/core/src/main/java/com/volmit/iris/core/gui/ExplorerStyle.java
deleted file mode 100644
index 36f84db44..000000000
--- a/core/src/main/java/com/volmit/iris/core/gui/ExplorerStyle.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Iris is a World Generator for Minecraft Bukkit Servers
- * Copyright (c) 2022 Arcane Arts (Volmit Software)
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.volmit.iris.core.gui;
-
-import javax.swing.AbstractButton;
-import javax.swing.BorderFactory;
-import javax.swing.JButton;
-import javax.swing.JToggleButton;
-
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
-
-final class ExplorerStyle {
- private static final java.awt.Color SELECTED_BACKGROUND = ACCENT.darker().darker();
-
- private ExplorerStyle() {
- }
-
- static JToggleButton toggleButton(String text) {
- JToggleButton button = new JToggleButton(text);
- applyFrame(button);
- button.addItemListener(event -> paintToggleState(button));
- paintToggleState(button);
- return button;
- }
-
- static JButton button(String text) {
- JButton button = new JButton(text);
- applyFrame(button);
- button.setBackground(FIELD_BACKGROUND);
- button.setForeground(PRIMARY_TEXT);
- return button;
- }
-
- private static void applyFrame(AbstractButton button) {
- button.setFocusPainted(false);
- button.setFont(ITEM_FONT);
- button.setContentAreaFilled(false);
- button.setOpaque(true);
- button.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createLineBorder(BORDER),
- BorderFactory.createEmptyBorder(5, 9, 5, 9)
- ));
- }
-
- private static void paintToggleState(JToggleButton button) {
- button.setBackground(button.isSelected() ? SELECTED_BACKGROUND : FIELD_BACKGROUND);
- button.setForeground(button.isSelected() ? PRIMARY_TEXT : SECONDARY_TEXT);
- }
-}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java
new file mode 100644
index 000000000..abb06cc53
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java
@@ -0,0 +1,981 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.Iris;
+import com.volmit.iris.core.gui.components.IrisRenderer;
+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;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.SwingUtilities;
+import javax.swing.Timer;
+import java.awt.Color;
+import java.awt.EventQueue;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.Toolkit;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseWheelEvent;
+import java.awt.image.BufferedImage;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+/**
+ * The tile map of the map window: draws the chosen view of the world and lets you drag, zoom and inspect it.
+ */
+final class MapCanvas extends JPanel {
+ private static final long serialVersionUID = 2094606939770332040L;
+ private static final Color PLAYER_COLOR = StudioTheme.SUCCESS;
+ private static final Color PLAYER_HALO = new Color(0x5F, 0xB8, 0x65, 48);
+ private static final Color MOB_COLOR = StudioTheme.ERROR;
+ private static final Color GRID_MINOR = new Color(255, 255, 255, 18);
+ private static final Color GRID_MAJOR = new Color(255, 255, 255, 38);
+ private static final Color TILE_EMPTY_A = new Color(0x232427);
+ private static final Color TILE_EMPTY_B = new Color(0x26272B);
+ private static final int CARD_PADDING = 12;
+ private static final int NOTIFICATION_MILLIS = 2_500;
+ 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 final UUID openerId;
+ private final Listener listener;
+ private final VisionRenderController controller;
+ private final StudioStatusBar statusBar = new StudioStatusBar();
+ private final Runnable hotloadHook;
+ private final Timer hoverTimer;
+ private final Timer markerTimer;
+ private final Timer notificationTimer;
+ private final Map notifications = new LinkedHashMap<>();
+
+ private Engine engine;
+ private IrisRenderer renderer;
+ private VisionOverlay overlay;
+ private VisionRenderController.Frame renderFrame;
+ private RenderType currentType = RenderType.BIOME;
+ private String viewLabel = MapView.BIOMES.label();
+ private List players = List.of();
+ private List entities = List.of();
+ private HoverInfo hoverInfo;
+ private Point cursor;
+ private Point dragOrigin;
+ private double centerX;
+ private double centerZ;
+ private double heightMaximum;
+ private double fluidHeight;
+ private long contentRevision = 1L;
+ private long previousPaintStartedNanos;
+ private double blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL;
+ private int paintCadenceFps;
+ private boolean grid;
+ private boolean entitiesVisible;
+ private boolean follow;
+ private boolean help;
+ private boolean debug;
+ private boolean detailedHover;
+ private boolean active = true;
+ private boolean closed;
+
+ MapCanvas(Engine engine, UUID openerId, Listener listener) {
+ this.engine = Objects.requireNonNull(engine, "engine");
+ this.openerId = openerId;
+ this.listener = Objects.requireNonNull(listener, "listener");
+ this.renderer = new IrisRenderer(engine);
+ this.overlay = new VisionOverlay(engine, openerId);
+ this.controller = new VisionRenderController(this::onTilesChanged);
+ this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent);
+ captureHeightRange();
+
+ setBackground(StudioTheme.EDITOR_BACKGROUND);
+ setFocusable(true);
+ installMouseHandlers();
+ addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent event) {
+ requestRender();
+ }
+ });
+
+ this.hoverTimer = singleShotTimer(90, this::requestHover);
+ this.markerTimer = new Timer(650, event -> refreshMarkers());
+ this.markerTimer.setCoalesce(true);
+ this.notificationTimer = new Timer(200, event -> expireNotifications());
+ this.notificationTimer.setCoalesce(true);
+ GuiHost.registerHotloadHook(hotloadHook);
+ markerTimer.start();
+ notificationTimer.start();
+ }
+
+ StudioStatusBar statusBar() {
+ return statusBar;
+ }
+
+ Engine engine() {
+ return engine;
+ }
+
+ double centerX() {
+ return centerX;
+ }
+
+ double centerZ() {
+ return centerZ;
+ }
+
+ boolean isGridVisible() {
+ return grid;
+ }
+
+ boolean areEntitiesVisible() {
+ return entitiesVisible;
+ }
+
+ boolean isFollowing() {
+ return follow;
+ }
+
+ void start() {
+ refreshMarkers();
+ requestRender();
+ }
+
+ void centerOn(double worldX, double worldZ) {
+ centerX = worldX;
+ centerZ = worldZ;
+ requestRender();
+ }
+
+ void showView(MapView view) {
+ if (!view.isTileMap()) {
+ return;
+ }
+ viewLabel = view.label();
+ if (view.renderType() == currentType) {
+ return;
+ }
+ currentType = view.renderType();
+ requestRender();
+ }
+
+ // 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;
+ if (active) {
+ requestRender();
+ }
+ }
+
+ void refreshContent() {
+ if (closed) {
+ return;
+ }
+ if (!ensureEngine()) {
+ notifyUser("No active Iris engine");
+ return;
+ }
+ renderer = new IrisRenderer(engine);
+ overlay = new VisionOverlay(engine, openerId);
+ contentRevision++;
+ captureHeightRange();
+ requestRender();
+ notifyUser("Refreshing");
+ }
+
+ void toggleGrid() {
+ grid = !grid;
+ repaint();
+ notifyUser(grid ? "Grid on" : "Grid off");
+ listener.onTogglesChanged();
+ }
+
+ void toggleEntities() {
+ entitiesVisible = !entitiesVisible;
+ if (entitiesVisible) {
+ refreshMarkers();
+ } else {
+ entities = List.of();
+ repaint();
+ }
+ notifyUser(entitiesVisible ? "Entities on" : "Entities off");
+ listener.onTogglesChanged();
+ }
+
+ void toggleFollow() {
+ follow = !follow;
+ if (follow && players.isEmpty()) {
+ follow = false;
+ notifyUser("No player in this world");
+ } else if (follow) {
+ GuiMarker player = players.get(0);
+ centerOn(player.worldX(), player.worldZ());
+ notifyUser("Following " + player.label());
+ } else {
+ notifyUser("Stopped following");
+ }
+ listener.onTogglesChanged();
+ }
+
+ void toggleHelp() {
+ help = !help;
+ repaint();
+ }
+
+ void toggleDebug() {
+ debug = !debug;
+ repaint();
+ }
+
+ void setDetailedHover(boolean detailed) {
+ if (detailedHover != detailed) {
+ detailedHover = detailed;
+ repaint();
+ }
+ }
+
+ void zoomIn() {
+ changeZoom(1D / KEYBOARD_ZOOM_FACTOR, cursor);
+ }
+
+ void zoomOut() {
+ changeZoom(KEYBOARD_ZOOM_FACTOR, cursor);
+ }
+
+ void resetZoom() {
+ changeZoom(DEFAULT_BLOCKS_PER_PIXEL / blocksPerPixel, null);
+ notifyUser("Zoom reset");
+ }
+
+ void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ hoverTimer.stop();
+ markerTimer.stop();
+ notificationTimer.stop();
+ GuiHost.unregisterHotloadHook(hotloadHook);
+ controller.close();
+ }
+
+ private static Timer singleShotTimer(int delay, Runnable task) {
+ Timer timer = new Timer(delay, event -> task.run());
+ timer.setRepeats(false);
+ timer.setCoalesce(true);
+ return timer;
+ }
+
+ private void installMouseHandlers() {
+ MouseAdapter handler = new MouseAdapter() {
+ @Override
+ public void mouseMoved(MouseEvent event) {
+ cursor = event.getPoint();
+ setDetailedHover(event.isShiftDown());
+ hoverTimer.restart();
+ refreshStatus();
+ }
+
+ @Override
+ public void mouseDragged(MouseEvent event) {
+ cursor = event.getPoint();
+ if (dragOrigin == null) {
+ return;
+ }
+ centerX += (dragOrigin.x - event.getX()) * blocksPerPixel;
+ centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel;
+ dragOrigin = event.getPoint();
+ stopFollowing();
+ hoverInfo = null;
+ requestRender();
+ }
+
+ @Override
+ public void mouseWheelMoved(MouseWheelEvent event) {
+ cursor = event.getPoint();
+ changeZoom(Math.pow(2D, event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT), cursor);
+ event.consume();
+ }
+
+ @Override
+ public void mousePressed(MouseEvent event) {
+ requestFocusInWindow();
+ cursor = event.getPoint();
+ if (event.isPopupTrigger()) {
+ showContextMenu(event);
+ } else if (SwingUtilities.isLeftMouseButton(event)) {
+ dragOrigin = event.getPoint();
+ }
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent event) {
+ dragOrigin = null;
+ if (event.isPopupTrigger()) {
+ showContextMenu(event);
+ }
+ }
+
+ @Override
+ public void mouseClicked(MouseEvent event) {
+ cursor = event.getPoint();
+ if (!SwingUtilities.isLeftMouseButton(event)) {
+ return;
+ }
+ if (event.isControlDown()) {
+ teleportTo(event.getPoint());
+ } else if (event.isAltDown()) {
+ openInEditor(event.getPoint());
+ } else if (event.getClickCount() == 2) {
+ showPalettesAt(event.getPoint());
+ }
+ }
+
+ @Override
+ public void mouseEntered(MouseEvent event) {
+ cursor = event.getPoint();
+ }
+
+ @Override
+ public void mouseExited(MouseEvent event) {
+ cursor = null;
+ hoverInfo = null;
+ hoverTimer.stop();
+ refreshStatus();
+ repaint();
+ }
+ };
+ addMouseListener(handler);
+ addMouseMotionListener(handler);
+ addMouseWheelListener(handler);
+ }
+
+ private void showContextMenu(MouseEvent event) {
+ Point point = event.getPoint();
+ int blockX = floorWorldCoordinate(screenToWorldX(point.x));
+ int blockZ = floorWorldCoordinate(screenToWorldZ(point.y));
+ JPopupMenu menu = new JPopupMenu();
+ 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)));
+ menu.addSeparator();
+ menu.add(menuItem("Copy coordinates (" + blockX + ", " + blockZ + ")", () -> copyCoordinates(blockX, blockZ)));
+ menu.show(this, point.x, point.y);
+ }
+
+ private static JMenuItem menuItem(String text, Runnable action) {
+ JMenuItem item = new JMenuItem(text);
+ item.addActionListener(event -> action.run());
+ return item;
+ }
+
+ private void copyCoordinates(int blockX, int blockZ) {
+ Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(blockX + " " + blockZ), null);
+ notifyUser("Copied " + blockX + ", " + blockZ);
+ }
+
+ private void showPalettesAt(Point point) {
+ listener.onShowPalettesAt(screenToWorldX(point.x), screenToWorldZ(point.y));
+ }
+
+ void findSurfaceBiome(double worldX, double worldZ, Consumer onFound) {
+ Engine probeEngine = engine;
+ controller.submitProbe(renderFrame, () -> {
+ IrisBiome biome = probeEngine.getSurfaceBiome(floorWorldCoordinate(worldX), floorWorldCoordinate(worldZ));
+ return biome == null ? null : biome.getLoadKey();
+ }, onFound);
+ }
+
+ @Override
+ protected void paintComponent(Graphics graphics) {
+ long started = System.nanoTime();
+ if (previousPaintStartedNanos > 0L) {
+ long interval = started - previousPaintStartedNanos;
+ paintCadenceFps = interval > 2_000_000_000L
+ ? 0
+ : (int) Math.min(240L, Math.round(1_000_000_000D / Math.max(1L, interval)));
+ }
+ previousPaintStartedNanos = started;
+ super.paintComponent(graphics);
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ renderTiles(canvas);
+ if (grid) {
+ renderGrid(canvas);
+ }
+ canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ renderMarkers(canvas);
+ renderLegend(canvas);
+ if (help) {
+ renderHelp(canvas);
+ } else if (debug) {
+ renderDebug(canvas);
+ }
+ renderHover(canvas);
+ renderNotifications(canvas);
+ } catch (Throwable error) {
+ Iris.debug("Vision paint failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
+ } finally {
+ canvas.dispose();
+ }
+ }
+
+ private void renderTiles(Graphics2D canvas) {
+ VisionRenderController.Frame frame = renderFrame;
+ canvas.setColor(StudioTheme.EDITOR_BACKGROUND);
+ canvas.fillRect(0, 0, getWidth(), getHeight());
+ if (frame == null) {
+ return;
+ }
+ canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+ double frameTileSpan = VisionRenderController.TILE_PIXELS * frame.renderBlocksPerPixel();
+ for (VisionRenderController.VisibleTile tile : frame.tiles()) {
+ double worldX = tile.tileX() * frameTileSpan;
+ double worldZ = tile.tileZ() * frameTileSpan;
+ int screenX = (int) Math.round(worldToScreenX(worldX));
+ 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);
+ continue;
+ }
+ canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
+ canvas.fillRect(screenX, screenY, tileWidth, tileHeight);
+ drawFallback(canvas, controller.fallback(frame, tile), screenX, screenY, tileWidth, tileHeight);
+ }
+ }
+
+ private static void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY,
+ int tileWidth, int tileHeight) {
+ if (fallback == null) {
+ return;
+ }
+ for (VisionRenderController.FallbackPiece piece : fallback.pieces()) {
+ int left = screenX + (int) Math.round(piece.offsetX() * tileWidth);
+ 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,
+ piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null);
+ }
+ }
+
+ private void renderGrid(Graphics2D canvas) {
+ double chunkPixels = 16D / blocksPerPixel;
+ if (chunkPixels >= 8D) {
+ canvas.setColor(GRID_MINOR);
+ drawWorldGrid(canvas, 16D, chunkPixels);
+ }
+ canvas.setColor(GRID_MAJOR);
+ double regionPixels = 512D / blocksPerPixel;
+ if (regionPixels >= 8D) {
+ drawWorldGrid(canvas, 512D, regionPixels);
+ }
+ }
+
+ private void drawWorldGrid(Graphics2D canvas, double blockSpacing, double pixelSpacing) {
+ double firstX = Math.floor(screenToWorldX(0D) / blockSpacing) * blockSpacing;
+ double firstZ = Math.floor(screenToWorldZ(0D) / blockSpacing) * blockSpacing;
+ for (double screen = worldToScreenX(firstX); screen <= getWidth(); screen += pixelSpacing) {
+ int screenX = (int) Math.round(screen);
+ canvas.drawLine(screenX, 0, screenX, getHeight());
+ }
+ for (double screen = worldToScreenZ(firstZ); screen <= getHeight(); screen += pixelSpacing) {
+ int screenY = (int) Math.round(screen);
+ canvas.drawLine(0, screenY, getWidth(), screenY);
+ }
+ }
+
+ private void renderMarkers(Graphics2D canvas) {
+ if (entitiesVisible) {
+ canvas.setColor(MOB_COLOR);
+ for (GuiMarker marker : entities) {
+ int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
+ int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
+ canvas.fillOval(screenX - 3, screenY - 3, 6, 6);
+ }
+ }
+
+ canvas.setFont(StudioTheme.smallFont());
+ for (GuiMarker marker : players) {
+ int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
+ int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
+ canvas.setColor(PLAYER_HALO);
+ canvas.fillOval(screenX - 12, screenY - 12, 24, 24);
+ canvas.setColor(PLAYER_COLOR);
+ canvas.fillOval(screenX - 5, screenY - 5, 10, 10);
+ canvas.setColor(StudioTheme.TEXT);
+ int labelWidth = canvas.getFontMetrics().stringWidth(marker.label());
+ canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 15);
+ }
+ if (entitiesVisible && detailedHover) {
+ renderNearestEntity(canvas);
+ }
+ }
+
+ private void renderNearestEntity(Graphics2D canvas) {
+ Point point = cursor;
+ if (point == null || entities.isEmpty()) {
+ return;
+ }
+ double worldX = screenToWorldX(point.x);
+ double worldZ = screenToWorldZ(point.y);
+ GuiMarker nearest = null;
+ double nearestDistance = Double.MAX_VALUE;
+ for (GuiMarker marker : entities) {
+ double deltaX = marker.worldX() - worldX;
+ double deltaZ = marker.worldZ() - worldZ;
+ double distance = deltaX * deltaX + deltaZ * deltaZ;
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ nearest = marker;
+ }
+ }
+ if (nearest == null) {
+ return;
+ }
+ ArrayList lines = new ArrayList<>();
+ lines.add(nearest.label());
+ lines.add("Position " + ((int) nearest.worldX()) + ", " + ((int) nearest.worldY()) + ", " + ((int) nearest.worldZ()));
+ if (nearest.maxHealth() > 0D) {
+ lines.add("Health " + Form.f(nearest.health(), 1) + " / " + Form.f(nearest.maxHealth(), 1));
+ }
+ drawCard(canvas, getWidth() - CARD_PADDING, CARD_PADDING, 1D, 0D, lines);
+ }
+
+ private void renderLegend(Graphics2D canvas) {
+ if (currentType != RenderType.HEIGHT) {
+ return;
+ }
+ int width = 244;
+ int height = 54;
+ int x = getWidth() - width - CARD_PADDING;
+ int y = getHeight() - height - CARD_PADDING;
+ StudioOverlay.drawCard(canvas, x, y, width, height);
+ int gradientX = x + CARD_PADDING;
+ int gradientY = y + CARD_PADDING;
+ int gradientWidth = width - CARD_PADDING * 2;
+ for (int pixel = 0; pixel < gradientWidth; pixel++) {
+ double sampleHeight = heightMaximum * pixel / Math.max(1D, gradientWidth - 1D);
+ canvas.setColor(new Color(IrisRenderer.heightColor(sampleHeight, heightMaximum, fluidHeight)));
+ canvas.drawLine(gradientX + pixel, gradientY, gradientX + pixel, gradientY + 14);
+ }
+ canvas.setFont(StudioTheme.smallFont());
+ canvas.setColor(StudioTheme.SECONDARY_TEXT);
+ canvas.drawString("0", gradientX, gradientY + 32);
+ String fluid = Integer.toString((int) Math.round(fluidHeight));
+ int fluidX = gradientX + (int) Math.round(gradientWidth * fluidHeight / Math.max(1D, heightMaximum));
+ canvas.drawString(fluid, Math.max(gradientX, fluidX - canvas.getFontMetrics().stringWidth(fluid) / 2), gradientY + 32);
+ String maximum = Integer.toString((int) Math.round(heightMaximum));
+ canvas.drawString(maximum, gradientX + gradientWidth - canvas.getFontMetrics().stringWidth(maximum), gradientY + 32);
+ }
+
+ private void renderHover(Graphics2D canvas) {
+ HoverInfo info = hoverInfo;
+ if (info == null) {
+ return;
+ }
+ ArrayList lines = new ArrayList<>();
+ lines.add(info.biomeName());
+ lines.add(info.regionName());
+ lines.add("Block " + ((int) info.worldX()) + ", " + ((int) info.worldZ()));
+ if (detailedHover) {
+ lines.add("Chunk " + ((int) info.worldX() >> 4) + ", " + ((int) info.worldZ() >> 4));
+ lines.add("Region file " + ((int) info.worldX() >> 9) + ", " + ((int) info.worldZ() >> 9));
+ lines.add("Key " + info.biomeKey());
+ lines.add("File " + info.biomeFile());
+ } else {
+ lines.add("Hold Shift for more, double-click for palettes");
+ }
+ int screenX = (int) Math.round(worldToScreenX(info.worldX())) + 16;
+ int screenY = (int) Math.round(worldToScreenZ(info.worldZ()));
+ drawCard(canvas, screenX, screenY, 0D, 0D, lines);
+ }
+
+ private void renderHelp(Graphics2D canvas) {
+ List keys = List.of("Drag", "Wheel / + -", "\\", "Double-click", "Right-click", "Ctrl+Click", "Alt+Click",
+ "Shift", "R", "F", "G", "E", "M", "1 to 0, P", "/");
+ List descriptions = List.of(
+ "Move the map",
+ "Zoom in and out",
+ "Reset zoom",
+ "Show the palettes of that spot",
+ "More actions for that spot",
+ "Teleport there",
+ "Open the biome file",
+ "More details while hovering",
+ "Redraw the map",
+ "Follow your player",
+ "Chunk and region grid",
+ "Show entities",
+ "Next view",
+ "Pick a view",
+ "Hide this help"
+ );
+ canvas.setFont(StudioTheme.boldFont());
+ int keyWidth = 0;
+ for (String key : keys) {
+ keyWidth = Math.max(keyWidth, canvas.getFontMetrics().stringWidth(key));
+ }
+ canvas.setFont(StudioTheme.font());
+ int descriptionWidth = 0;
+ for (String description : descriptions) {
+ descriptionWidth = Math.max(descriptionWidth, canvas.getFontMetrics().stringWidth(description));
+ }
+ int lineHeight = 22;
+ int width = keyWidth + descriptionWidth + CARD_PADDING * 3 + 8;
+ int height = keys.size() * lineHeight + CARD_PADDING * 2;
+ StudioOverlay.drawCard(canvas, CARD_PADDING, CARD_PADDING, width, height);
+ for (int index = 0; index < keys.size(); index++) {
+ int y = CARD_PADDING * 2 + 10 + index * lineHeight;
+ canvas.setFont(StudioTheme.boldFont());
+ canvas.setColor(StudioTheme.LINK);
+ canvas.drawString(keys.get(index), CARD_PADDING * 2, y);
+ canvas.setFont(StudioTheme.font());
+ canvas.setColor(StudioTheme.SECONDARY_TEXT);
+ canvas.drawString(descriptions.get(index), CARD_PADDING * 2 + keyWidth + 18, y);
+ }
+ }
+
+ private void renderDebug(Graphics2D canvas) {
+ VisionRenderController.Progress progress = controller.progress(renderFrame);
+ VisionRenderController.Frame frame = renderFrame;
+ ArrayList lines = new ArrayList<>();
+ lines.add("Tiles " + progress.ready() + " / " + progress.total() + " drawn");
+ lines.add("Render threads busy " + progress.activeWorkers());
+ lines.add("Tile scale " + (frame == null ? "-" : Form.f(frame.renderBlocksPerPixel(), 0)) + " blocks/px");
+ lines.add(paintCadenceFps + " paints per second");
+ drawCard(canvas, CARD_PADDING, getHeight() - CARD_PADDING, 0D, 1D, lines);
+ }
+
+ private void renderNotifications(Graphics2D canvas) {
+ if (notifications.isEmpty()) {
+ return;
+ }
+ String text = String.join(" · ", notifications.keySet());
+ canvas.setFont(StudioTheme.font());
+ int textWidth = canvas.getFontMetrics().stringWidth(text);
+ int width = textWidth + CARD_PADDING * 2;
+ int height = 34;
+ int x = (getWidth() - width) / 2;
+ int y = getHeight() - height - 16;
+ StudioOverlay.drawCard(canvas, x, y, width, height);
+ canvas.setColor(StudioTheme.TEXT);
+ canvas.drawString(text, x + CARD_PADDING, y + 22);
+ }
+
+ private void drawCard(Graphics2D canvas, int anchorX, int anchorY, double pushX, double pushY, List lines) {
+ canvas.setFont(StudioTheme.font());
+ int lineHeight = canvas.getFontMetrics().getHeight();
+ int width = 0;
+ for (int index = 0; index < lines.size(); index++) {
+ canvas.setFont(index == 0 ? StudioTheme.boldFont() : StudioTheme.font());
+ width = Math.max(width, canvas.getFontMetrics().stringWidth(lines.get(index)));
+ }
+ width += CARD_PADDING * 2;
+ int height = lines.size() * lineHeight + CARD_PADDING * 2 - 2;
+ int x = (int) Math.round(anchorX - width * pushX);
+ int y = (int) Math.round(anchorY - height * pushY);
+ x = Math.max(CARD_PADDING, Math.min(getWidth() - width - CARD_PADDING, x));
+ y = Math.max(CARD_PADDING, Math.min(getHeight() - height - CARD_PADDING, y));
+ StudioOverlay.drawCard(canvas, x, y, width, height);
+ for (int index = 0; index < lines.size(); index++) {
+ boolean title = index == 0;
+ canvas.setFont(title ? StudioTheme.boldFont() : StudioTheme.font());
+ canvas.setColor(title ? StudioTheme.TEXT : StudioTheme.SECONDARY_TEXT);
+ canvas.drawString(lines.get(index), x + CARD_PADDING, y + CARD_PADDING + lineHeight - 3 + index * lineHeight);
+ }
+ }
+
+ private void onTilesChanged() {
+ repaint();
+ refreshStatus();
+ }
+
+ private void requestRender() {
+ if (closed || !active || getWidth() < 1 || getHeight() < 1 || !ensureEngine()) {
+ repaint();
+ return;
+ }
+ hoverInfo = null;
+ VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec(
+ renderer,
+ currentType,
+ contentRevision,
+ centerX,
+ centerZ,
+ blocksPerPixel,
+ getWidth(),
+ getHeight()
+ );
+ renderFrame = controller.request(spec);
+ repaint();
+ refreshStatus();
+ }
+
+ private void refreshStatus() {
+ int viewWidthBlocks = (int) (blocksPerPixel * getWidth());
+ int viewHeightBlocks = (int) (blocksPerPixel * getHeight());
+ statusBar.showSummary(viewLabel + " " + formatScale(blocksPerPixel) + " blocks/px "
+ + Form.f(viewWidthBlocks) + " × " + Form.f(viewHeightBlocks) + " blocks");
+ VisionRenderController.Progress progress = controller.progress(renderFrame);
+ boolean drawing = progress.completion() < 1D;
+ statusBar.showActivity(drawing ? "Drawing " + progress.ready() + " of " + progress.total() + " tiles" : "");
+ statusBar.showProgress(progress.completion());
+ Point point = cursor;
+ double worldX = point == null ? centerX : screenToWorldX(point.x);
+ double worldZ = point == null ? centerZ : screenToWorldZ(point.y);
+ statusBar.showPosition("X " + Form.f(floorWorldCoordinate(worldX)) + " Z " + Form.f(floorWorldCoordinate(worldZ)));
+ }
+
+ private static String formatScale(double blocksPerPixel) {
+ if (blocksPerPixel >= 1D) {
+ return Form.f(blocksPerPixel, 1);
+ }
+ return "1/" + String.format(Locale.ROOT, "%.0f", 1D / blocksPerPixel);
+ }
+
+ private boolean ensureEngine() {
+ Engine activeEngine = engine;
+ if (activeEngine != null && !activeEngine.isClosed() && activeEngine.getComplex() != null) {
+ return true;
+ }
+ try {
+ Engine reacquired = GuiHost.findActiveEngine();
+ if (reacquired == null || reacquired.isClosed() || reacquired.getComplex() == null) {
+ return false;
+ }
+ engine = reacquired;
+ renderer = new IrisRenderer(reacquired);
+ overlay = new VisionOverlay(reacquired, openerId);
+ contentRevision++;
+ captureHeightRange();
+ return true;
+ } catch (Throwable error) {
+ Iris.debug("Vision engine reacquisition failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
+ return false;
+ }
+ }
+
+ private void captureHeightRange() {
+ heightMaximum = Math.max(1D, engine.getHeight());
+ IrisDimension dimension = engine.getDimension();
+ fluidHeight = dimension == null
+ ? 0D
+ : Math.max(0D, Math.min(heightMaximum, dimension.getFluidHeight()));
+ }
+
+ private void refreshMarkers() {
+ if (closed || overlay == null) {
+ return;
+ }
+ try {
+ List nextPlayers = overlay.lastKnownPlayers();
+ players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers);
+ if (entitiesVisible) {
+ overlay.requestEntities(next -> EventQueue.invokeLater(() -> {
+ if (!closed && entitiesVisible) {
+ entities = next == null ? List.of() : List.copyOf(next);
+ repaint();
+ }
+ }));
+ } else if (!entities.isEmpty()) {
+ entities = List.of();
+ }
+ if (follow && !players.isEmpty()) {
+ GuiMarker player = players.get(0);
+ if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) {
+ centerOn(player.worldX(), player.worldZ());
+ }
+ }
+ if (active) {
+ repaint();
+ }
+ } catch (Throwable error) {
+ Iris.debug("Vision marker refresh failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
+ }
+ }
+
+ private void requestHover() {
+ Point point = cursor;
+ VisionRenderController.Frame frame = renderFrame;
+ Engine probeEngine = engine;
+ if (closed || !active || point == null || frame == null || probeEngine == null || probeEngine.getComplex() == null) {
+ return;
+ }
+ double worldX = screenToWorldX(point.x);
+ double worldZ = screenToWorldZ(point.y);
+ controller.submitProbe(
+ frame,
+ () -> sampleHover(probeEngine, worldX, worldZ),
+ info -> {
+ hoverInfo = info;
+ 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())
+ );
+ }
+
+ private void stopFollowing() {
+ if (follow) {
+ follow = false;
+ listener.onTogglesChanged();
+ }
+ }
+
+ private void notifyUser(String message) {
+ notifications.put(message, System.currentTimeMillis() + NOTIFICATION_MILLIS);
+ repaint();
+ }
+
+ private void expireNotifications() {
+ long now = System.currentTimeMillis();
+ boolean changed = false;
+ Iterator> iterator = notifications.entrySet().iterator();
+ while (iterator.hasNext()) {
+ if (iterator.next().getValue() <= now) {
+ iterator.remove();
+ changed = true;
+ }
+ }
+ if (changed) {
+ repaint();
+ }
+ }
+
+ private void changeZoom(double factor, Point anchor) {
+ if (!Double.isFinite(factor) || factor <= 0D) {
+ return;
+ }
+ Point effectiveAnchor = anchor == null ? new Point(getWidth() / 2, getHeight() / 2) : anchor;
+ VisionViewport next = new VisionViewport(centerX, centerZ, blocksPerPixel).zoomAt(
+ effectiveAnchor.x,
+ effectiveAnchor.y,
+ getWidth(),
+ getHeight(),
+ factor,
+ VisionRenderController.MINIMUM_VIEW_BLOCKS_PER_PIXEL,
+ VisionRenderController.MAXIMUM_BLOCKS_PER_PIXEL
+ );
+ if (next.blocksPerPixel() == blocksPerPixel) {
+ return;
+ }
+ centerX = next.centerX();
+ centerZ = next.centerZ();
+ blocksPerPixel = next.blocksPerPixel();
+ stopFollowing();
+ hoverInfo = null;
+ requestRender();
+ }
+
+ private double screenToWorldX(double screenX) {
+ return centerX + (screenX - getWidth() * 0.5D) * blocksPerPixel;
+ }
+
+ private double screenToWorldZ(double screenY) {
+ return centerZ + (screenY - getHeight() * 0.5D) * blocksPerPixel;
+ }
+
+ private double worldToScreenX(double worldX) {
+ return getWidth() * 0.5D + (worldX - centerX) / blocksPerPixel;
+ }
+
+ private double worldToScreenZ(double worldZ) {
+ return getHeight() * 0.5D + (worldZ - centerZ) / blocksPerPixel;
+ }
+
+ private void openInEditor(Point point) {
+ VisionOverlay activeOverlay = overlay;
+ if (activeOverlay == null || point == null) {
+ return;
+ }
+ double worldX = screenToWorldX(point.x);
+ double worldZ = screenToWorldZ(point.y);
+ RenderType type = currentType;
+ controller.submitProbe(
+ renderFrame,
+ () -> activeOverlay.openInEditor(worldX, worldZ, type),
+ opened -> opened.ifPresent(fileName -> notifyUser("Opened " + fileName))
+ );
+ }
+
+ private void teleportTo(Point point) {
+ if (overlay == null || point == null) {
+ notifyUser("No player in this world");
+ return;
+ }
+ int worldX = floorWorldCoordinate(screenToWorldX(point.x));
+ int worldZ = floorWorldCoordinate(screenToWorldZ(point.y));
+ overlay.teleport(worldX, worldZ);
+ notifyUser("Teleporting to " + worldX + ", " + worldZ);
+ }
+
+ static int floorWorldCoordinate(double coordinate) {
+ return (int) StrictMath.floor(coordinate);
+ }
+
+ interface Listener {
+ void onTogglesChanged();
+
+ void onShowPalettesAt(double worldX, double worldZ);
+ }
+
+ private record HoverInfo(
+ double worldX,
+ double worldZ,
+ String biomeName,
+ String regionName,
+ String biomeKey,
+ String biomeFile
+ ) {
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapView.java b/core/src/main/java/com/volmit/iris/core/gui/MapView.java
new file mode 100644
index 000000000..a35d361e9
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapView.java
@@ -0,0 +1,118 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.components.RenderType;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * Everything the map window can show, in the order and groups of its view list.
+ */
+enum MapView {
+ BIOMES("Biomes", "The biome at each spot, in the biome's colour", Group.WORLD, RenderType.BIOME, KeyEvent.VK_1),
+ REGIONS("Regions", "The region each spot belongs to", Group.WORLD, RenderType.REGION, KeyEvent.VK_2),
+ HEIGHT("Height", "Terrain height, from deep water to snowy peaks", Group.WORLD, RenderType.HEIGHT, KeyEvent.VK_3),
+ CONTINENTS("Continents", "Land, coast or sea, judged by each biome's first height generator",
+ Group.WORLD, RenderType.CONTINENT, KeyEvent.VK_4),
+ LAND_BIOMES("Land biomes", "The land biome each spot would get, also where the sea is",
+ Group.BIOME_STREAMS, RenderType.BIOME_LAND, KeyEvent.VK_5),
+ SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
+ Group.BIOME_STREAMS, RenderType.BIOME_SEA, KeyEvent.VK_6),
+ CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, RenderType.CAVE_LAND, KeyEvent.VK_7),
+ OBJECT_LOAD("Objects", "How densely each biome places objects, brighter is denser",
+ Group.LOAD, RenderType.OBJECT_LOAD, KeyEvent.VK_8),
+ DECORATOR_LOAD("Decorators", "How densely each biome places decorations, brighter is denser",
+ Group.LOAD, RenderType.DECORATOR_LOAD, KeyEvent.VK_9),
+ LAYER_LOAD("Layers", "How many block layers each biome stacks, brighter is more",
+ Group.LOAD, RenderType.LAYER_LOAD, KeyEvent.VK_0),
+ PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
+ Group.BLOCKS, null, KeyEvent.VK_P);
+
+ private final String label;
+ private final String description;
+ private final Group group;
+ private final RenderType renderType;
+ private final int shortcutKey;
+
+ MapView(String label, String description, Group group, RenderType renderType, int shortcutKey) {
+ this.label = label;
+ this.description = description;
+ this.group = group;
+ this.renderType = renderType;
+ this.shortcutKey = shortcutKey;
+ }
+
+ String label() {
+ return label;
+ }
+
+ String description() {
+ return description;
+ }
+
+ Group group() {
+ return group;
+ }
+
+ /**
+ * The tile renderer's mode for this view, or null for the palette view, which draws itself.
+ */
+ RenderType renderType() {
+ return renderType;
+ }
+
+ int shortcutKey() {
+ return shortcutKey;
+ }
+
+ String shortcutText() {
+ return KeyEvent.getKeyText(shortcutKey);
+ }
+
+ boolean isTileMap() {
+ return renderType != null;
+ }
+
+ static MapView forShortcut(int keyCode) {
+ for (MapView view : values()) {
+ if (view.shortcutKey == keyCode) {
+ return view;
+ }
+ }
+ return null;
+ }
+
+ enum Group {
+ WORLD("World"),
+ BIOME_STREAMS("Biome layers"),
+ LOAD("Generation load"),
+ BLOCKS("Blocks");
+
+ private final String label;
+
+ Group(String label) {
+ this.label = label;
+ }
+
+ String label() {
+ return label;
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java b/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java
new file mode 100644
index 000000000..c298376fb
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java
@@ -0,0 +1,167 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import javax.swing.BorderFactory;
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.DefaultListModel;
+import javax.swing.JList;
+import javax.swing.ListSelectionModel;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+/**
+ * The map window's list of views, grouped under headers, each row showing the key that opens it.
+ */
+final class MapViewList extends JList {
+ private static final long serialVersionUID = -4130872409914437415L;
+ private static final int ROW_HEIGHT = 26;
+
+ private final DefaultListModel rows = new DefaultListModel<>();
+ private int lastViewRow;
+
+ MapViewList(Consumer onViewChosen) {
+ fillRows();
+ setModel(rows);
+ setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+ setFixedCellHeight(ROW_HEIGHT);
+ setCellRenderer(new RowRenderer());
+ setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
+ addListSelectionListener(event -> {
+ if (event.getValueIsAdjusting() || skipHeaderRow()) {
+ return;
+ }
+ Row selected = getSelectedValue();
+ if (selected == null) {
+ return;
+ }
+ lastViewRow = getSelectedIndex();
+ onViewChosen.accept(selected.view());
+ });
+ }
+
+ void select(MapView view) {
+ for (int row = 0; row < rows.size(); row++) {
+ if (rows.get(row).view() == view) {
+ lastViewRow = row;
+ setSelectedIndex(row);
+ ensureIndexIsVisible(row);
+ return;
+ }
+ }
+ }
+
+ private void fillRows() {
+ MapView.Group currentGroup = null;
+ for (MapView view : MapView.values()) {
+ if (view.group() != currentGroup) {
+ currentGroup = view.group();
+ rows.addElement(Row.header(currentGroup));
+ }
+ rows.addElement(Row.of(view));
+ }
+ }
+
+ // Headers can't be chosen: clicking one keeps the current view, and the arrow keys carry on past it.
+ private boolean skipHeaderRow() {
+ int row = getSelectedIndex();
+ if (row < 0 || !rows.get(row).isHeader()) {
+ return false;
+ }
+ int step = row >= lastViewRow ? 1 : -1;
+ int next = nextViewRow(row, step);
+ if (next < 0) {
+ next = nextViewRow(row, -step);
+ }
+ setSelectedIndex(next < 0 ? lastViewRow : next);
+ ensureIndexIsVisible(getSelectedIndex());
+ return true;
+ }
+
+ private int nextViewRow(int from, int step) {
+ for (int row = from + step; row >= 0 && row < rows.size(); row += step) {
+ if (!rows.get(row).isHeader()) {
+ return row;
+ }
+ }
+ return -1;
+ }
+
+ record Row(MapView.Group group, MapView view) {
+ static Row header(MapView.Group group) {
+ return new Row(group, null);
+ }
+
+ static Row of(MapView view) {
+ return new Row(view.group(), view);
+ }
+
+ boolean isHeader() {
+ return view == null;
+ }
+ }
+
+ private static final class RowRenderer extends DefaultListCellRenderer {
+ private static final long serialVersionUID = 6302156810211578474L;
+ private String shortcut;
+
+ @Override
+ public Component getListCellRendererComponent(JList> list, Object value, int index, boolean selected, boolean focused) {
+ Row row = (Row) value;
+ super.getListCellRendererComponent(list, row.isHeader() ? "" : row.view().label(), index, selected && !row.isHeader(), false);
+ if (row.isHeader()) {
+ setText(row.group().label().toUpperCase(Locale.ROOT));
+ setFont(StudioTheme.sectionFont());
+ setForeground(StudioTheme.MUTED_TEXT);
+ setBorder(BorderFactory.createEmptyBorder(index == 0 ? 0 : 6, 6, 0, 6));
+ setToolTipText(null);
+ shortcut = null;
+ return this;
+ }
+ setFont(StudioTheme.font());
+ setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 10));
+ setToolTipText(row.view().description());
+ shortcut = row.view().shortcutText();
+ return this;
+ }
+
+ @Override
+ protected void paintComponent(Graphics graphics) {
+ super.paintComponent(graphics);
+ if (shortcut == null) {
+ return;
+ }
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ canvas.setFont(StudioTheme.smallFont());
+ canvas.setColor(StudioTheme.MUTED_TEXT);
+ int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
+ int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
+ canvas.drawString(shortcut, getWidth() - getInsets().right - textWidth, baseline);
+ } finally {
+ canvas.dispose();
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
new file mode 100644
index 000000000..592aadda4
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
@@ -0,0 +1,352 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import javax.swing.AbstractAction;
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JRootPane;
+import javax.swing.JSplitPane;
+import javax.swing.JToggleButton;
+import javax.swing.JToolBar;
+import javax.swing.KeyStroke;
+import javax.swing.text.JTextComponent;
+import java.awt.BorderLayout;
+import java.awt.CardLayout;
+import java.awt.Dimension;
+import java.awt.EventQueue;
+import java.awt.KeyboardFocusManager;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The studio map: one window showing the world as tiles in several views, with the palette explorer as its Palettes view.
+ */
+public final class MapWindow implements MapCanvas.Listener {
+ private static final String MAP_CARD = "map";
+ private static final String PALETTES_CARD = "palettes";
+ private static final int SIDEBAR_WIDTH = 270;
+ private static final double PALETTE_RECENTER_DISTANCE_BLOCKS = 1D;
+ private static final double VIEW_LIST_SHARE_WITH_PALETTES = 0.34D;
+
+ private final JFrame frame;
+ private final PaletteExplorerStart start;
+ private final MapCanvas canvas;
+ private final MapViewList viewList;
+ private final JLabel viewTitle = new JLabel();
+ private final JPanel content = new JPanel(new CardLayout());
+ private final JPanel statusBars = new JPanel(new CardLayout());
+ private final JPanel sidebar = new JPanel(new BorderLayout());
+ private final JPanel inspectorSlot = new JPanel(new BorderLayout());
+ private final JComponent viewListScroll;
+ private final List mapControls = new ArrayList<>();
+ private final List paletteControls = new ArrayList<>();
+ 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 NoiseExplorerGUI paletteExplorer;
+ private MapView currentView;
+ private double paletteCenterX;
+ private double paletteCenterZ;
+
+ private MapWindow(PaletteExplorerStart start) {
+ this.start = start;
+ this.frame = GuiHost.createFrame("Iris Map");
+ this.canvas = new MapCanvas(start.engine(), start.playerId(), this);
+ this.viewList = new MapViewList(this::showView);
+ this.viewListScroll = StudioToolbar.scroll(viewList);
+ canvas.centerOn(start.centerX(), start.centerZ());
+ buildFrame();
+ }
+
+ public static void openMap(PaletteExplorerStart start) {
+ EventQueue.invokeLater(() -> open(start, MapView.BIOMES));
+ }
+
+ public static void openPalettes(PaletteExplorerStart start) {
+ EventQueue.invokeLater(() -> open(start, MapView.PALETTES));
+ }
+
+ private static void open(PaletteExplorerStart start, MapView initialView) {
+ MapWindow window = new MapWindow(start);
+ window.frame.setVisible(true);
+ window.canvas.start();
+ window.showView(initialView);
+ }
+
+ private void buildFrame() {
+ content.add(canvas, MAP_CARD);
+ statusBars.add(canvas.statusBar(), MAP_CARD);
+ sidebar.setPreferredSize(new Dimension(SIDEBAR_WIDTH, 0));
+ sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, StudioTheme.SEPARATOR));
+ sidebar.add(viewListScroll, BorderLayout.CENTER);
+
+ frame.setLayout(new BorderLayout());
+ frame.add(buildToolbar(), BorderLayout.NORTH);
+ frame.add(sidebar, BorderLayout.WEST);
+ frame.add(content, BorderLayout.CENTER);
+ frame.add(inspectorSlot, BorderLayout.EAST);
+ frame.add(statusBars, BorderLayout.SOUTH);
+ installShortcuts(frame.getRootPane());
+ frame.setSize(1440, 880);
+ frame.setMinimumSize(new Dimension(820, 520));
+ frame.setLocationRelativeTo(null);
+ frame.addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosed(WindowEvent event) {
+ close();
+ }
+ });
+ }
+
+ private JToolBar buildToolbar() {
+ JToolBar toolbar = StudioToolbar.create();
+ viewTitle.setFont(StudioTheme.titleFont());
+ viewTitle.setBorder(BorderFactory.createEmptyBorder(0, 6, 0, 14));
+ toolbar.add(viewTitle);
+
+ mapControls.add(StudioToolbar.button("Refresh", "Redraw the map with the latest pack files (R)", canvas::refreshContent));
+ mapControls.add(StudioToolbar.button("Reset zoom", "Go back to the default zoom (\\)", canvas::resetZoom));
+ mapControls.add(gridToggle);
+ mapControls.add(entitiesToggle);
+ mapControls.add(followToggle);
+ gridToggle.addActionListener(event -> canvas.toggleGrid());
+ entitiesToggle.addActionListener(event -> canvas.toggleEntities());
+ followToggle.addActionListener(event -> canvas.toggleFollow());
+ mapControls.forEach(toolbar::add);
+
+ paletteControls.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetPaletteView));
+ paletteControls.forEach(toolbar::add);
+
+ toolbar.add(Box.createHorizontalGlue());
+ JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp);
+ mapControls.add(help);
+ toolbar.add(help);
+ return toolbar;
+ }
+
+ private void showView(MapView view) {
+ if (view == null || view == currentView) {
+ return;
+ }
+ MapView previousView = currentView;
+ currentView = view;
+ viewList.select(view);
+ viewTitle.setText(view.label());
+ viewTitle.setToolTipText(view.description());
+ if (view.isTileMap()) {
+ showMap(previousView, view);
+ } else {
+ showPalettes(previousView);
+ }
+ frame.getRootPane().revalidate();
+ frame.getRootPane().repaint();
+ }
+
+ private void showMap(MapView previousView, MapView view) {
+ if (previousView == MapView.PALETTES && paletteExplorer != null) {
+ canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
+ }
+ canvas.showView(view);
+ canvas.setActive(true);
+ setControlsVisible(mapControls, true);
+ setControlsVisible(paletteControls, false);
+ useSidebar(viewListScroll);
+ inspectorSlot.removeAll();
+ showCard(MAP_CARD);
+ canvas.requestFocusInWindow();
+ }
+
+ private void showPalettes(MapView previousView) {
+ boolean firstOpen = paletteExplorer == null;
+ if (firstOpen) {
+ createPaletteExplorer(previousView == null);
+ } else {
+ followMapIntoPalettes();
+ }
+ canvas.setActive(false);
+ setControlsVisible(mapControls, false);
+ setControlsVisible(paletteControls, true);
+ JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, viewListScroll, paletteExplorer.paletteSidebar());
+ split.setBorder(BorderFactory.createEmptyBorder());
+ split.setResizeWeight(0D);
+ split.setDividerLocation(viewListHeightBesidePalettes());
+ useSidebar(split);
+ inspectorSlot.removeAll();
+ inspectorSlot.add(paletteExplorer.paletteInspector(), BorderLayout.CENTER);
+ showCard(PALETTES_CARD);
+ EventQueue.invokeLater(() -> viewList.select(currentView));
+ if (firstOpen) {
+ paletteExplorer.openViewer();
+ }
+ }
+
+ // The palettes need the room more than the view list, which scrolls once it is squeezed.
+ private int viewListHeightBesidePalettes() {
+ int fullHeight = viewList.getPreferredSize().height + 4;
+ if (sidebar.getHeight() < 1) {
+ return fullHeight;
+ }
+ return Math.min(fullHeight, (int) (sidebar.getHeight() * VIEW_LIST_SHARE_WITH_PALETTES));
+ }
+
+ private void createPaletteExplorer(boolean openedOnPalettes) {
+ PaletteExplorerStart explorerStart = openedOnPalettes
+ ? start
+ : new PaletteExplorerStart(start.engine(), start.worldId(), start.playerId(), null, canvas.centerX(), canvas.centerZ());
+ paletteExplorer = NoiseExplorerGUI.createPaletteExplorer(explorerStart);
+ content.add(paletteExplorer, PALETTES_CARD);
+ statusBars.add(paletteExplorer.statusBar(), PALETTES_CARD);
+ rememberPaletteCenter(explorerStart.centerX(), explorerStart.centerZ());
+ if (!openedOnPalettes) {
+ showSurfaceBiomePalettes(explorerStart.centerX(), explorerStart.centerZ());
+ }
+ }
+
+ // Coming back from the map only moves the palettes when the map was moved, so a chosen layer survives a quick look.
+ private void followMapIntoPalettes() {
+ double movedX = canvas.centerX() - paletteCenterX;
+ double movedZ = canvas.centerZ() - paletteCenterZ;
+ if (Math.hypot(movedX, movedZ) < PALETTE_RECENTER_DISTANCE_BLOCKS) {
+ return;
+ }
+ paletteExplorer.centerOn(canvas.centerX(), canvas.centerZ());
+ rememberPaletteCenter(canvas.centerX(), canvas.centerZ());
+ showSurfaceBiomePalettes(canvas.centerX(), canvas.centerZ());
+ }
+
+ @Override
+ public void onShowPalettesAt(double worldX, double worldZ) {
+ canvas.centerOn(worldX, worldZ);
+ showView(MapView.PALETTES);
+ }
+
+ private void showSurfaceBiomePalettes(double worldX, double worldZ) {
+ canvas.findSurfaceBiome(worldX, worldZ, biomeKey -> {
+ if (paletteExplorer != null) {
+ paletteExplorer.showPalettesAt(worldX, worldZ, biomeKey);
+ }
+ });
+ }
+
+ private void rememberPaletteCenter(double worldX, double worldZ) {
+ paletteCenterX = worldX;
+ paletteCenterZ = worldZ;
+ }
+
+ @Override
+ public void onTogglesChanged() {
+ gridToggle.setSelected(canvas.isGridVisible());
+ entitiesToggle.setSelected(canvas.areEntitiesVisible());
+ followToggle.setSelected(canvas.isFollowing());
+ }
+
+ private void resetPaletteView() {
+ if (paletteExplorer != null) {
+ paletteExplorer.resetViewport();
+ }
+ }
+
+ private void useSidebar(JComponent component) {
+ sidebar.removeAll();
+ sidebar.add(component, BorderLayout.CENTER);
+ }
+
+ private void showCard(String card) {
+ ((CardLayout) content.getLayout()).show(content, card);
+ ((CardLayout) statusBars.getLayout()).show(statusBars, card);
+ }
+
+ private static void setControlsVisible(List controls, boolean visible) {
+ controls.forEach(control -> control.setVisible(visible));
+ }
+
+ private void installShortcuts(JRootPane rootPane) {
+ for (MapView view : MapView.values()) {
+ bind(rootPane, KeyStroke.getKeyStroke(view.shortcutKey(), 0), "view-" + view.name(), () -> showView(view));
+ }
+ bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "next-view", this::showNextView);
+ bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "refresh", this::refreshOrReset);
+ bindMap(rootPane, KeyEvent.VK_F, 0, "follow", canvas::toggleFollow);
+ 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_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);
+ bindMap(rootPane, KeyEvent.VK_MINUS, 0, "zoom-out", canvas::zoomOut);
+ bindMap(rootPane, KeyEvent.VK_SUBTRACT, 0, "zoom-out-keypad", canvas::zoomOut);
+ bindMap(rootPane, KeyEvent.VK_BACK_SLASH, 0, "reset-zoom", canvas::resetZoom);
+ bindMap(rootPane, KeyEvent.VK_SLASH, 0, "help", canvas::toggleHelp);
+ bindMap(rootPane, KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK, "help-question", canvas::toggleHelp);
+ bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, KeyEvent.SHIFT_DOWN_MASK, false), "details-on",
+ () -> canvas.setDetailedHover(true));
+ bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, 0, true), "details-off", () -> canvas.setDetailedHover(false));
+ }
+
+ private void bindMap(JRootPane rootPane, int keyCode, int modifiers, String name, Runnable action) {
+ bind(rootPane, KeyStroke.getKeyStroke(keyCode, modifiers), name, () -> {
+ if (currentView != null && currentView.isTileMap()) {
+ action.run();
+ }
+ });
+ }
+
+ // Keys typed into a search box belong to that box, not to the window's shortcuts.
+ private static void bind(JRootPane rootPane, KeyStroke keyStroke, String name, Runnable action) {
+ rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, name);
+ rootPane.getActionMap().put(name, new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
+ action.run();
+ }
+ }
+ });
+ }
+
+ private void showNextView() {
+ MapView[] views = MapView.values();
+ showView(views[(currentView.ordinal() + 1) % views.length]);
+ }
+
+ private void refreshOrReset() {
+ if (currentView.isTileMap()) {
+ canvas.refreshContent();
+ } else {
+ resetPaletteView();
+ }
+ }
+
+ private void close() {
+ canvas.close();
+ if (paletteExplorer != null) {
+ paletteExplorer.close();
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java
index 741ed95fe..8d9904c18 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java
@@ -18,6 +18,7 @@
package com.volmit.iris.core.gui;
+import com.formdev.flatlaf.FlatClientProperties;
import com.volmit.iris.Iris;
import com.volmit.iris.core.IrisSettings;
import com.volmit.iris.core.loader.IrisData;
@@ -31,16 +32,18 @@ import com.volmit.iris.util.noise.CNG;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
+import javax.swing.Box;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
-import javax.swing.JButton;
import javax.swing.JComboBox;
+import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
+import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.SwingConstants;
@@ -53,15 +56,14 @@ import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
-import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
-import java.awt.GridLayout;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
+import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
@@ -82,31 +84,16 @@ import java.util.function.LongFunction;
public final class NoiseExplorerGUI extends JPanel {
private static final long serialVersionUID = 2094606939770332040L;
- private static final Color BACKGROUND = new Color(12, 15, 22);
- static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29);
- static final Color PANEL_BACKGROUND = new Color(23, 27, 38);
- static final Color FIELD_BACKGROUND = new Color(29, 34, 47);
- private static final Color SELECTED_BACKGROUND = new Color(42, 55, 78);
- static final Color PRIMARY_TEXT = new Color(230, 234, 242);
- static final Color SECONDARY_TEXT = new Color(151, 160, 178);
- static final Color ACCENT = new Color(99, 161, 255);
- static final Color BORDER = new Color(48, 55, 72);
- static final Color ERROR = new Color(255, 116, 116);
- private static final Color GRID = new Color(255, 255, 255, 15);
+ private static final Color GRID = new Color(255, 255, 255, 12);
private static final Color CROSSHAIR = new Color(255, 255, 255, 125);
- private static final Color LEGEND_BACKGROUND = new Color(10, 12, 18, 215);
- private static final Font MONOSPACED_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12);
- static final Font HEADER_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11);
- static final Font ITEM_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12);
- static final Font LEGEND_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 10);
private static final int SIDEBAR_WIDTH = 270;
- private static final int STATUS_HEIGHT = 50;
+ private static final int LEGEND_WIDTH = 190;
+ private static final int LEGEND_HEIGHT = 40;
private static final int REFINEMENT_DELAY_MILLIS = 180;
private static final long DEFAULT_SEED = 12345L;
private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D;
private static final double PALETTE_START_BLOCKS_PER_PIXEL = 1D / 16D;
private static final long COARSE_SAMPLE_BUDGET = 4_096L;
- private static final long MAX_REFINED_SAMPLE_BUDGET = 48_000L;
private static final double REFINEMENT_TARGET_MILLIS = 250D;
private static final double PALETTE_REFINEMENT_TARGET_MILLIS = 2_000D;
private static final double INTERACTIVE_FRAME_TARGET_MILLIS = 33D;
@@ -151,11 +138,11 @@ public final class NoiseExplorerGUI extends JPanel {
private final DefaultListModel sourceModel = new DefaultListModel<>();
private final JList sourceList = new JList<>(sourceModel);
private final JTextField searchField = new JTextField();
- private final JTextField seedField = new JTextField();
+ private final JTextField seedField = new JTextField(9);
private final JLabel selectedLabel = new JLabel("No source selected");
- private final JLabel progressLabel = new JLabel("Opening noise explorer...");
private final JLabel catalogLabel = new JLabel("Pack catalog waiting");
private final JComboBox palettePicker = new JComboBox<>(NoisePalette.values());
+ private final StudioStatusBar statusBar = new StudioStatusBar();
private final PaletteInspectorPanel paletteInspector = new PaletteInspectorPanel(new PaletteInspectorListener());
private final PaletteNavigator paletteNavigator;
private PlayerLocationTracker locationTracker;
@@ -175,11 +162,13 @@ public final class NoiseExplorerGUI extends JPanel {
private Point dragPoint;
private long seed;
private long viewRevision;
+ private int lastSourceRow = -1;
private boolean catalogLoading;
private boolean sourceLoading;
private boolean rendering;
private boolean disposed;
private boolean awaitingInitialPackSource;
+ private String requestedPaletteBiome;
private String errorMessage;
private NoiseExplorerGUI(ExplorerMode mode, Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator,
@@ -188,6 +177,7 @@ public final class NoiseExplorerGUI extends JPanel {
this.paletteStart = paletteStart;
this.paletteNavigator = new PaletteNavigator(this::selectPaletteLayer);
this.awaitingInitialPackSource = mode == ExplorerMode.PALETTES;
+ this.requestedPaletteBiome = paletteStart == null ? null : paletteStart.biomeKey();
this.initialViewport = paletteStart == null
? new NoiseViewport(0D, 0D, 1D)
: new NoiseViewport(paletteStart.centerX(), paletteStart.centerZ(), PALETTE_START_BLOCKS_PER_PIXEL);
@@ -210,9 +200,12 @@ public final class NoiseExplorerGUI extends JPanel {
this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview());
this.resizeTimer.setRepeats(false);
this.fixedSources = buildFixedSources();
- setBackground(BACKGROUND);
+ setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
installInputHandlers();
+ catalogLabel.setFont(StudioTheme.smallFont());
+ catalogLabel.setForeground(StudioTheme.SECONDARY_TEXT);
+ statusBar.showActivity(mode == ExplorerMode.PALETTES ? "Opening palettes..." : "Opening noise explorer...");
GuiHost.registerHotloadHook(hotloadHook);
}
@@ -220,14 +213,7 @@ public final class NoiseExplorerGUI extends JPanel {
Engine engine = GuiHost.findActiveEngine();
EventQueue.invokeLater(() -> {
NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.NOISE, engine, null, null, null, DEFAULT_SEED, null);
- buildFrame("Noise Explorer", explorer);
- });
- }
-
- public static void launchPalettes(PaletteExplorerStart start) {
- EventQueue.invokeLater(() -> {
- NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.PALETTES, start.engine(), null, null, null, DEFAULT_SEED, start);
- buildFrame("Palette Explorer", explorer);
+ buildNoiseFrame("Noise Explorer", explorer);
});
}
@@ -244,18 +230,30 @@ public final class NoiseExplorerGUI extends JPanel {
initialSeed,
null
);
- buildFrame("Noise Explorer: " + displayName, explorer);
+ buildNoiseFrame("Noise Explorer: " + displayName, explorer);
});
}
- private static void buildFrame(String title, NoiseExplorerGUI explorer) {
+ // The palette explorer has no window of its own: the map window shows it as its Palettes view.
+ static NoiseExplorerGUI createPaletteExplorer(PaletteExplorerStart start) {
+ return new NoiseExplorerGUI(ExplorerMode.PALETTES, start.engine(), null, null, null, DEFAULT_SEED, start);
+ }
+
+ private static void buildNoiseFrame(String title, NoiseExplorerGUI explorer) {
JFrame frame = GuiHost.createFrame(title);
- frame.getContentPane().setBackground(BACKGROUND);
frame.setLayout(new BorderLayout());
- frame.add(explorer.buildSidebar(), BorderLayout.WEST);
+ frame.add(explorer.buildNoiseToolbar(), BorderLayout.NORTH);
+ frame.add(explorer.buildNoiseSidebar(), BorderLayout.WEST);
frame.add(explorer, BorderLayout.CENTER);
- frame.add(explorer.paletteInspector, BorderLayout.EAST);
- frame.setSize(1440, 820);
+ frame.add(explorer.statusBar, BorderLayout.SOUTH);
+ frame.getRootPane().getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke('R'), "reset-view");
+ frame.getRootPane().getActionMap().put("reset-view", new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ explorer.resetViewport();
+ }
+ });
+ frame.setSize(1440, 860);
frame.setMinimumSize(new Dimension(760, 500));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() {
@@ -294,28 +292,39 @@ public final class NoiseExplorerGUI extends JPanel {
return executor;
}
- JPanel buildSidebar() {
+ private JToolBar buildNoiseToolbar() {
+ JToolBar toolbar = StudioToolbar.create();
+ selectedLabel.setFont(StudioTheme.boldFont());
+ selectedLabel.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 10));
+ toolbar.add(selectedLabel);
+ toolbar.addSeparator();
+ toolbar.add(StudioToolbar.label("Seed"));
+ seedField.setMaximumSize(seedField.getPreferredSize());
+ seedField.addActionListener(event -> applySeed());
+ toolbar.add(seedField);
+ toolbar.add(StudioToolbar.button("Apply", "Use this seed", this::applySeed));
+ toolbar.addSeparator();
+ toolbar.add(StudioToolbar.label("Colours"));
+ palettePicker.setSelectedItem(palette);
+ palettePicker.setMaximumSize(palettePicker.getPreferredSize());
+ palettePicker.addActionListener(event -> {
+ NoisePalette nextPalette = (NoisePalette) palettePicker.getSelectedItem();
+ if (nextPalette != null && nextPalette != palette) {
+ palette = nextPalette;
+ requestPreview();
+ }
+ });
+ toolbar.add(palettePicker);
+ toolbar.add(Box.createHorizontalGlue());
+ toolbar.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetViewport));
+ return toolbar;
+ }
+
+ private JPanel buildNoiseSidebar() {
JPanel sidebar = new JPanel(new BorderLayout());
sidebar.setPreferredSize(new Dimension(SIDEBAR_WIDTH, 0));
- sidebar.setBackground(SIDEBAR_BACKGROUND);
- sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, BORDER));
-
- JPanel heading = new JPanel(new BorderLayout());
- heading.setBackground(SIDEBAR_BACKGROUND);
- heading.setBorder(BorderFactory.createEmptyBorder(12, 12, 10, 12));
- selectedLabel.setForeground(PRIMARY_TEXT);
- selectedLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
- progressLabel.setForeground(SECONDARY_TEXT);
- progressLabel.setFont(ITEM_FONT);
- heading.add(selectedLabel, BorderLayout.NORTH);
- heading.add(progressLabel, BorderLayout.SOUTH);
-
- configureTextField(searchField);
- searchField.putClientProperty("JTextField.placeholderText", "Search...");
- searchField.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
- BorderFactory.createEmptyBorder(8, 12, 8, 12)
- ));
+ sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, StudioTheme.SEPARATOR));
+ StudioToolbar.makeSearchField(searchField, "Search noise...");
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
@@ -332,125 +341,126 @@ public final class NoiseExplorerGUI extends JPanel {
populateSourceList();
}
});
+ // Down or Enter in the search box jumps to the results, so the list can be searched without the mouse.
+ searchField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "focus-results");
+ searchField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "focus-results");
+ searchField.getActionMap().put("focus-results", new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ focusFirstSource();
+ }
+ });
- JPanel north = new JPanel(new BorderLayout());
- north.setBackground(SIDEBAR_BACKGROUND);
- north.add(heading, BorderLayout.NORTH);
- if (mode == ExplorerMode.NOISE) {
- north.add(searchField, BorderLayout.SOUTH);
- }
-
- sourceList.setBackground(SIDEBAR_BACKGROUND);
- sourceList.setForeground(SECONDARY_TEXT);
sourceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
sourceList.setCellRenderer(new SourceCellRenderer());
sourceList.addListSelectionListener(event -> {
if (event.getValueIsAdjusting()) {
return;
}
+ if (skipHeaderRow()) {
+ return;
+ }
ListItem item = sourceList.getSelectedValue();
+ lastSourceRow = sourceList.getSelectedIndex();
if (item != null && item.source() != null) {
selectSource(item.source());
}
});
- JScrollPane scrollPane = new JScrollPane(sourceList);
- scrollPane.setBorder(BorderFactory.createEmptyBorder());
- scrollPane.getVerticalScrollBar().setUnitIncrement(18);
- JPanel controls = buildControls();
- sidebar.add(north, BorderLayout.NORTH);
- sidebar.add(mode == ExplorerMode.PALETTES ? paletteNavigator : scrollPane, BorderLayout.CENTER);
- sidebar.add(controls, BorderLayout.SOUTH);
+ sidebar.add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
+ sidebar.add(StudioToolbar.scroll(sourceList), BorderLayout.CENTER);
+ sidebar.add(StudioToolbar.padded(catalogLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
populateSourceList();
return sidebar;
}
- private JPanel buildControls() {
- JPanel controls = new JPanel(new GridLayout(0, 1, 0, 7));
- controls.setBackground(PANEL_BACKGROUND);
- controls.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
- BorderFactory.createEmptyBorder(10, 12, 12, 12)
- ));
+ JComponent paletteSidebar() {
+ JPanel sidebar = new JPanel(new BorderLayout());
+ sidebar.add(paletteNavigator, BorderLayout.CENTER);
+ sidebar.add(StudioToolbar.padded(catalogLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
+ return sidebar;
+ }
- catalogLabel.setForeground(SECONDARY_TEXT);
- catalogLabel.setFont(ITEM_FONT);
- controls.add(catalogLabel);
+ PaletteInspectorPanel paletteInspector() {
+ return paletteInspector;
+ }
- if (mode == ExplorerMode.NOISE) {
- addSeedControls(controls);
- addPaletteControls(controls);
+ StudioStatusBar statusBar() {
+ return statusBar;
+ }
+
+ double centerX() {
+ return viewport.centerX();
+ }
+
+ double centerZ() {
+ return viewport.centerZ();
+ }
+
+ void centerOn(double worldX, double worldZ) {
+ viewport = new NoiseViewport(worldX, worldZ, viewport.blocksPerPixel());
+ requestPreview();
+ }
+
+ // Jumps to a spot and opens the palette of the biome there, once the catalog has loaded if it hasn't yet.
+ void showPalettesAt(double worldX, double worldZ, String biomeKey) {
+ centerOn(worldX, worldZ);
+ if (biomeKey == null) {
+ return;
+ }
+ requestedPaletteBiome = biomeKey;
+ if (!packSources.isEmpty()) {
+ selectPaletteLayer(biomeKey, 0);
}
-
- JButton resetView = createButton("Reset view");
- resetView.addActionListener(event -> resetViewport());
- controls.add(resetView);
- return controls;
}
- private void addPaletteControls(JPanel controls) {
- JLabel paletteTitle = new JLabel("Palette");
- paletteTitle.setForeground(SECONDARY_TEXT);
- paletteTitle.setFont(HEADER_FONT);
- controls.add(paletteTitle);
- palettePicker.setSelectedItem(palette);
- palettePicker.setBackground(FIELD_BACKGROUND);
- palettePicker.setForeground(PRIMARY_TEXT);
- palettePicker.addActionListener(event -> {
- NoisePalette nextPalette = (NoisePalette) palettePicker.getSelectedItem();
- if (nextPalette != null && nextPalette != palette) {
- palette = nextPalette;
- requestPreview();
+ // Header rows can't be chosen, so moving onto one with the arrow keys carries on to the next real source.
+ private boolean skipHeaderRow() {
+ int row = sourceList.getSelectedIndex();
+ if (row < 0 || sourceModel.get(row).source() != null) {
+ return false;
+ }
+ int step = row >= lastSourceRow ? 1 : -1;
+ int next = nextSourceRow(row, step);
+ if (next < 0) {
+ next = nextSourceRow(row, -step);
+ }
+ if (next < 0) {
+ sourceList.clearSelection();
+ } else {
+ sourceList.setSelectedIndex(next);
+ sourceList.ensureIndexIsVisible(next);
+ }
+ return true;
+ }
+
+ private int nextSourceRow(int from, int step) {
+ for (int row = from + step; row >= 0 && row < sourceModel.size(); row += step) {
+ if (sourceModel.get(row).source() != null) {
+ return row;
}
- });
- controls.add(palettePicker);
+ }
+ return -1;
}
- private void addSeedControls(JPanel controls) {
- JLabel seedTitle = new JLabel("Seed");
- seedTitle.setForeground(SECONDARY_TEXT);
- seedTitle.setFont(HEADER_FONT);
- controls.add(seedTitle);
- JPanel seedRow = new JPanel(new BorderLayout(7, 0));
- seedRow.setOpaque(false);
- configureTextField(seedField);
- JButton applySeed = createButton("Apply");
- applySeed.addActionListener(event -> applySeed());
- seedField.addActionListener(event -> applySeed());
- seedRow.add(seedField, BorderLayout.CENTER);
- seedRow.add(applySeed, BorderLayout.EAST);
- controls.add(seedRow);
- }
-
- private static JButton createButton(String text) {
- JButton button = new JButton(text);
- button.setBackground(FIELD_BACKGROUND);
- button.setForeground(PRIMARY_TEXT);
- button.setFocusPainted(false);
- button.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createLineBorder(BORDER),
- BorderFactory.createEmptyBorder(5, 9, 5, 9)
- ));
- return button;
- }
-
- private static void configureTextField(JTextField field) {
- field.setBackground(FIELD_BACKGROUND);
- field.setForeground(PRIMARY_TEXT);
- field.setCaretColor(PRIMARY_TEXT);
- field.setFont(ITEM_FONT);
- field.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createLineBorder(BORDER),
- BorderFactory.createEmptyBorder(6, 8, 6, 8)
- ));
+ private void focusFirstSource() {
+ int row = sourceList.getSelectedIndex() >= 0 ? sourceList.getSelectedIndex() : nextSourceRow(-1, 1);
+ if (row < 0) {
+ return;
+ }
+ sourceList.setSelectedIndex(row);
+ sourceList.ensureIndexIsVisible(row);
+ sourceList.requestFocusInWindow();
}
void openViewer() {
loadCatalog();
if (mode == ExplorerMode.PALETTES) {
loadBlockTextures();
- if (paletteStart != null) {
+ if (paletteStart != null && paletteStart.playerId() != null) {
locationTracker = PlayerLocationTracker.follow(paletteStart.playerId(), paletteStart.worldId(), paletteNavigator::showLocation);
+ } else {
+ paletteNavigator.showUntracked();
}
}
SourceItem initial = findInitialSource();
@@ -512,7 +522,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
long revision = catalogLoadRevision.incrementAndGet();
catalogLoading = true;
- catalogLabel.setForeground(SECONDARY_TEXT);
+ catalogLabel.setForeground(StudioTheme.SECONDARY_TEXT);
catalogLabel.setText("Loading pack catalog...");
submitLatest(catalogExecutor, () -> {
try {
@@ -664,10 +674,10 @@ public final class NoiseExplorerGUI extends JPanel {
paletteNavigator.showCatalog(paletteCatalog);
}
if (error != null) {
- catalogLabel.setForeground(ERROR);
+ catalogLabel.setForeground(StudioTheme.ERROR);
catalogLabel.setText("Pack catalog unavailable");
} else {
- catalogLabel.setForeground(SECONDARY_TEXT);
+ catalogLabel.setForeground(StudioTheme.SECONDARY_TEXT);
catalogLabel.setText(catalogSummary(sources));
}
boolean reloadSelectedPack = false;
@@ -693,7 +703,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
private void selectInitialPackSource(List sources) {
- String initialSourceId = paletteStart == null || paletteStart.biomeKey() == null ? null : paletteSourceId(paletteStart.biomeKey(), 0);
+ String initialSourceId = requestedPaletteBiome == null ? null : paletteSourceId(requestedPaletteBiome, 0);
SourceItem requested = initialSourceId == null ? null : findSourceById(initialSourceId, sources);
SourceItem initial = requested == null ? sources.get(0) : requested;
if (!selectSourceInList(initial.id())) {
@@ -724,9 +734,8 @@ public final class NoiseExplorerGUI extends JPanel {
rendering = false;
errorMessage = "Pack source removed during hotload: " + removedSource.text();
selectedLabel.setText("No source selected");
- progressLabel.setForeground(ERROR);
- progressLabel.setText("Selected pack source was removed");
sourceList.clearSelection();
+ refreshStatus();
repaint();
}
@@ -817,8 +826,7 @@ public final class NoiseExplorerGUI extends JPanel {
sourceLoading = true;
rendering = false;
errorMessage = null;
- progressLabel.setForeground(SECONDARY_TEXT);
- progressLabel.setText("Loading source...");
+ refreshStatus();
repaint();
submitLatest(sourceExecutor, () -> {
try {
@@ -844,8 +852,7 @@ public final class NoiseExplorerGUI extends JPanel {
sourceLoading = false;
errorMessage = null;
renderResult = null;
- progressLabel.setForeground(SECONDARY_TEXT);
- progressLabel.setText(source.usesSeed() ? "Ready · seed " + seed : "Ready");
+ refreshStatus();
requestPreview();
}
@@ -857,8 +864,7 @@ public final class NoiseExplorerGUI extends JPanel {
sourceLoading = false;
rendering = false;
errorMessage = readableError(error);
- progressLabel.setForeground(ERROR);
- progressLabel.setText("Source failed to load");
+ refreshStatus();
repaint();
}
@@ -892,7 +898,7 @@ public final class NoiseExplorerGUI extends JPanel {
String rawSeed = seedField.getText().trim();
try {
long nextSeed = Long.parseLong(rawSeed);
- seedField.setForeground(PRIMARY_TEXT);
+ seedField.putClientProperty(FlatClientProperties.OUTLINE, null);
if (nextSeed == seed) {
return;
}
@@ -901,9 +907,8 @@ public final class NoiseExplorerGUI extends JPanel {
loadSelectedSource();
}
} catch (NumberFormatException error) {
- seedField.setForeground(ERROR);
- progressLabel.setForeground(ERROR);
- progressLabel.setText("Seed must be a whole number");
+ seedField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR);
+ statusBar.showError("Seed must be a whole number");
}
}
@@ -966,7 +971,7 @@ public final class NoiseExplorerGUI extends JPanel {
requestRender(sampleStep);
}
- // Smooth noise looks fine upscaled, but blocks and their textures only read correctly at full resolution.
+ // Blocks and their textures are slower to sample, so palettes get longer refinement passes than plain noise.
private long refinedSampleBudget(NoiseRenderCoordinator.Result currentResult) {
double targetMilliseconds = mode == ExplorerMode.PALETTES ? PALETTE_REFINEMENT_TARGET_MILLIS : REFINEMENT_TARGET_MILLIS;
return timedSampleBudget(currentResult, targetMilliseconds);
@@ -988,10 +993,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
private long maximumSampleBudget() {
- if (mode == ExplorerMode.PALETTES) {
- return Math.max(COARSE_SAMPLE_BUDGET, (long) getWidth() * renderHeight());
- }
- return MAX_REFINED_SAMPLE_BUDGET;
+ return Math.max(COARSE_SAMPLE_BUDGET, (long) getWidth() * renderHeight());
}
private NoiseRenderCoordinator.Request requestRender(int sampleStep) {
@@ -1042,13 +1044,13 @@ public final class NoiseExplorerGUI extends JPanel {
@Override
public void mouseMoved(MouseEvent event) {
pointer = event.getPoint();
- repaint();
+ refreshPointerStatus();
}
@Override
public void mouseExited(MouseEvent event) {
pointer = null;
- repaint();
+ refreshPointerStatus();
}
@Override
@@ -1075,16 +1077,9 @@ public final class NoiseExplorerGUI extends JPanel {
resizeTimer.restart();
}
});
- getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke('R'), "reset-view");
- getActionMap().put("reset-view", new AbstractAction() {
- @Override
- public void actionPerformed(ActionEvent event) {
- resetViewport();
- }
- });
}
- private void resetViewport() {
+ void resetViewport() {
viewport = initialViewport;
requestPreview();
}
@@ -1137,7 +1132,6 @@ public final class NoiseExplorerGUI extends JPanel {
}
drawCrosshair(canvas, getWidth(), renderHeight);
drawLegend(canvas, getWidth());
- drawStatus(canvas, getWidth(), renderHeight);
} finally {
canvas.dispose();
}
@@ -1176,7 +1170,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
private static void drawEmptyState(Graphics2D canvas, int width, int height) {
- canvas.setColor(BACKGROUND);
+ canvas.setColor(StudioTheme.EDITOR_BACKGROUND);
canvas.fillRect(0, 0, width, height);
canvas.setColor(GRID);
for (int x = 0; x < width; x += 48) {
@@ -1198,101 +1192,110 @@ public final class NoiseExplorerGUI extends JPanel {
private void drawLegend(Graphics2D canvas, int width) {
NoiseColorScale colorScale = activeColorScale();
- int legendWidth = 190;
- int legendHeight = 40;
- int legendX = Math.max(10, width - legendWidth - 12);
+ int legendX = Math.max(10, width - LEGEND_WIDTH - 12);
int legendY = 12;
- canvas.setColor(LEGEND_BACKGROUND);
- canvas.fillRoundRect(legendX, legendY, legendWidth, legendHeight, 10, 10);
+ canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ StudioOverlay.drawCard(canvas, legendX, legendY, LEGEND_WIDTH, LEGEND_HEIGHT);
int gradientX = legendX + 9;
int gradientY = legendY + 8;
- int gradientWidth = legendWidth - 18;
+ int gradientWidth = LEGEND_WIDTH - 18;
for (int x = 0; x < gradientWidth; x++) {
double normalized = x / (double) Math.max(1, gradientWidth - 1);
canvas.setColor(colorScale.displayColorNormalized(normalized));
canvas.drawLine(gradientX + x, gradientY, gradientX + x, gradientY + 8);
}
- canvas.setFont(LEGEND_FONT);
- canvas.setColor(PRIMARY_TEXT);
+ canvas.setFont(StudioTheme.smallFont());
+ canvas.setColor(StudioTheme.TEXT);
canvas.drawString(formatNumber(colorScale.minimum()), gradientX, legendY + 31);
String maximum = formatNumber(colorScale.maximum());
int maximumWidth = canvas.getFontMetrics().stringWidth(maximum);
canvas.drawString(maximum, gradientX + gradientWidth - maximumWidth, legendY + 31);
String label = colorScale.label();
int labelWidth = canvas.getFontMetrics().stringWidth(label);
- canvas.drawString(label, legendX + ((legendWidth - labelWidth) / 2), legendY + 31);
+ canvas.drawString(label, legendX + ((LEGEND_WIDTH - labelWidth) / 2), legendY + 31);
}
- private void drawStatus(Graphics2D canvas, int width, int renderHeight) {
- canvas.setColor(PANEL_BACKGROUND);
- canvas.fillRect(0, renderHeight, width, STATUS_HEIGHT);
- canvas.setColor(BORDER);
- canvas.drawLine(0, renderHeight, width, renderHeight);
- canvas.setFont(MONOSPACED_FONT);
- canvas.setColor(errorMessage == null ? PRIMARY_TEXT : ERROR);
- canvas.drawString(primaryStatus(), 10, renderHeight + 19);
- canvas.setColor(SECONDARY_TEXT);
- canvas.drawString(secondaryStatus(width, renderHeight), 10, renderHeight + 38);
- }
-
- private String primaryStatus() {
+ private void refreshStatus() {
if (errorMessage != null) {
- return "Error: " + errorMessage;
+ statusBar.showError("Error: " + errorMessage);
+ } else {
+ statusBar.showSummary(primaryStatus());
}
- String sourceName = selectedSource == null ? "No source" : selectedSource.text();
- if (sourceLoading) {
- return sourceName + " · loading source";
- }
- if (rendering) {
- return sourceName + " · rendering preview";
- }
- if (renderResult == null) {
- return sourceName + " · waiting for preview";
+ refreshPointerStatus();
+ }
+
+ // Only the status bar changes when the pointer moves, so moving the mouse never redraws the map.
+ private void refreshPointerStatus() {
+ int renderHeight = renderHeight();
+ Point currentPointer = pointer;
+ double screenX = currentPointer == null ? getWidth() / 2D : currentPointer.getX();
+ double screenZ = currentPointer == null ? renderHeight / 2D : Math.min(currentPointer.getY(), renderHeight);
+ double worldX = viewport.worldX(screenX, getWidth());
+ double worldZ = viewport.worldZ(screenZ, renderHeight);
+ statusBar.showPosition(String.format(Locale.ROOT, "X %.1f Z %.1f %s blocks/px", worldX, worldZ,
+ formatScale(viewport.blocksPerPixel())));
+ statusBar.showActivity(pointerDetail(worldX, worldZ));
+ }
+
+ private String pointerDetail(double worldX, double worldZ) {
+ if (catalogLoading && renderResult == null) {
+ return "Loading catalog...";
}
NoiseRenderCoordinator.Result result = renderResult;
- String quality = result.request().sampleStep() == 1
- ? "full"
- : "sample 1:" + result.request().sampleStep();
- return sourceName + " · " + quality + " · " + formatNumber(result.centerValue())
- + " center · " + String.format(Locale.ROOT, "%.1f ms", result.milliseconds());
- }
-
- private String secondaryStatus(int width, int renderHeight) {
- Point currentPointer = pointer;
- double screenX = currentPointer == null ? width / 2D : currentPointer.getX();
- double screenZ = currentPointer == null ? renderHeight / 2D : Math.min(currentPointer.getY(), renderHeight);
- double worldX = viewport.worldX(screenX, width);
- double worldZ = viewport.worldZ(screenZ, renderHeight);
- String coordinates = String.format(
- Locale.ROOT,
- "X %.2f Z %.2f · %.5f blocks/px",
- worldX,
- worldZ,
- viewport.blocksPerPixel()
- );
- if (renderResult == null) {
- return coordinates + (catalogLoading ? " · loading catalog" : "");
+ if (result == null) {
+ return "";
}
PaletteLayerPreview preview = palettePreview;
if (preview != null) {
- return coordinates + " · " + describePaletteAt(preview, worldX, worldZ);
+ return describePaletteAt(preview, worldX, worldZ);
+ }
+ long clipped = result.underflow() + result.overflow();
+ return "range " + formatNumber(result.minimum()) + " to " + formatNumber(result.maximum())
+ + " clipped " + clipped + " invalid " + result.invalid();
+ }
+
+ private String primaryStatus() {
+ String sourceName = selectedSource == null ? "No source" : sourceTitle(selectedSource);
+ if (sourceLoading) {
+ return sourceName + " · loading";
+ }
+ if (rendering && renderResult == null) {
+ return sourceName + " · rendering";
+ }
+ if (renderResult == null) {
+ return sourceName;
}
NoiseRenderCoordinator.Result result = renderResult;
- long clipped = result.underflow() + result.overflow();
- return coordinates + " · range " + formatNumber(result.minimum()) + ".." + formatNumber(result.maximum())
- + " · clipped " + clipped + " invalid " + result.invalid();
+ String quality = result.request().sampleStep() == 1
+ ? "full detail"
+ : "1 sample per " + result.request().sampleStep() + " px";
+ String seedText = selectedSource != null && selectedSource.usesSeed() ? " · seed " + seed : "";
+ return sourceName + seedText + " · " + quality + " · " + String.format(Locale.ROOT, "%.0f ms", result.milliseconds());
+ }
+
+ private static String sourceTitle(SourceItem source) {
+ return source.category() == NoiseCategory.PACK_PALETTES
+ ? source.rawName() + " · layer " + paletteLayerOf(source)
+ : source.text();
}
private static String describePaletteAt(PaletteLayerPreview preview, double worldX, double worldZ) {
double noise = preview.sample(worldX, worldZ);
if (Double.isNaN(noise)) {
- return "layer doesn't reach this depth here";
+ return "The layer doesn't reach this depth here";
}
- return preview.entryForNoise(noise).block() + " · noise " + formatNumber(noise);
+ return preview.entryForNoise(noise).block() + " noise " + formatNumber(noise);
}
private int renderHeight() {
- return Math.max(1, getHeight() - STATUS_HEIGHT);
+ return Math.max(1, getHeight());
+ }
+
+ private static String formatScale(double blocksPerPixel) {
+ if (blocksPerPixel >= 1D) {
+ return String.format(Locale.ROOT, "%.2f", blocksPerPixel);
+ }
+ return "1/" + String.format(Locale.ROOT, "%.0f", 1D / blocksPerPixel);
}
private static String formatNumber(double value) {
@@ -1445,7 +1448,7 @@ public final class NoiseExplorerGUI extends JPanel {
EventQueue.invokeLater(() -> {
if (!disposed && request.revision() == viewRevision) {
rendering = true;
- repaint();
+ refreshStatus();
}
});
}
@@ -1460,6 +1463,7 @@ public final class NoiseExplorerGUI extends JPanel {
errorMessage = null;
renderResult = result;
repaint();
+ refreshStatus();
if (finishInteractiveFrame(result.request())) {
return;
}
@@ -1493,6 +1497,7 @@ public final class NoiseExplorerGUI extends JPanel {
rendering = false;
errorMessage = readableError(error);
repaint();
+ refreshStatus();
});
}
}
@@ -1503,18 +1508,15 @@ public final class NoiseExplorerGUI extends JPanel {
boolean selected, boolean focus) {
ListItem item = (ListItem) value;
boolean header = item.source() == null;
- super.getListCellRendererComponent(list, item.text(), index, selected && !header, false);
- setOpaque(true);
+ super.getListCellRendererComponent(list, header ? item.text().toUpperCase(Locale.ROOT) : item.text(), index,
+ selected && !header, false);
if (header) {
- setFont(HEADER_FONT);
- setForeground(ACCENT);
- setBackground(SIDEBAR_BACKGROUND);
- setBorder(BorderFactory.createEmptyBorder(11, 11, 4, 11));
+ setFont(StudioTheme.sectionFont());
+ setForeground(StudioTheme.MUTED_TEXT);
+ setBorder(BorderFactory.createEmptyBorder(12, 12, 4, 12));
} else {
- setFont(ITEM_FONT);
- setForeground(selected ? Color.WHITE : SECONDARY_TEXT);
- setBackground(selected ? SELECTED_BACKGROUND : SIDEBAR_BACKGROUND);
- setBorder(BorderFactory.createEmptyBorder(4, 20, 4, 10));
+ setFont(StudioTheme.font());
+ setBorder(BorderFactory.createEmptyBorder(3, 20, 3, 12));
}
setHorizontalAlignment(SwingConstants.LEFT);
return this;
diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java
index 26d985cc2..dc65d0642 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java
@@ -21,10 +21,8 @@ package com.volmit.iris.core.gui;
import com.volmit.iris.util.function.NoiseProvider;
import java.awt.image.BufferedImage;
-import java.awt.image.DataBufferInt;
import java.util.Objects;
-import java.util.concurrent.ArrayBlockingQueue;
-import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
@@ -35,10 +33,11 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
final class NoiseRenderCoordinator implements AutoCloseable {
+ private static final int BANDS_PER_WORKER = 4;
+
private final int workerCount;
private final Listener listener;
- private final ThreadFactory workerThreadFactory;
- private final ThreadFactory coordinatorThreadFactory;
+ private final ThreadPoolExecutor workers;
private final AtomicReference latestRequested = new AtomicReference<>();
private final AtomicLong latestRevision = new AtomicLong();
private final AtomicBoolean closed = new AtomicBoolean();
@@ -51,18 +50,16 @@ final class NoiseRenderCoordinator implements AutoCloseable {
this.workerCount = Math.max(1, workerCount);
this.listener = Objects.requireNonNull(listener, "listener");
AtomicInteger threadIds = new AtomicInteger();
- workerThreadFactory = runnable -> {
+ ThreadFactory workerThreadFactory = runnable -> {
Thread thread = new Thread(runnable, "Iris Noise Renderer " + threadIds.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
};
- coordinatorThreadFactory = runnable -> {
- Thread thread = new Thread(runnable, "Iris Noise Coordinator " + threadIds.incrementAndGet());
- thread.setDaemon(true);
- thread.setPriority(Thread.MIN_PRIORITY);
- return thread;
- };
+ workers = new ThreadPoolExecutor(this.workerCount, this.workerCount, 30L, TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>(), workerThreadFactory);
+ workers.allowCoreThreadTimeOut(true);
+ GuiHost.interruptOnPluginShutdown(workers);
}
void request(Request request) {
@@ -71,15 +68,23 @@ final class NoiseRenderCoordinator implements AutoCloseable {
return;
}
latestRevision.accumulateAndGet(request.revision(), Math::max);
- cancelActiveRender();
- RenderGeneration generation = createGeneration(request);
+ RenderGeneration generation = new RenderGeneration(request, Math.min(workerCount * BANDS_PER_WORKER, outputHeight(request)));
latestRequested.set(generation);
- generation.start();
+ listener.onRenderStarted(request);
+ for (int band = 0; band < generation.bandCount(); band++) {
+ int bandIndex = band;
+ try {
+ workers.execute(() -> renderBand(generation, bandIndex));
+ } catch (RejectedExecutionException rejected) {
+ generation.fail(rejected);
+ finishBand(generation);
+ }
+ }
}
void cancel(long revision) {
latestRevision.accumulateAndGet(revision, Math::max);
- cancelActiveRender();
+ latestRequested.set(null);
}
boolean isClosed() {
@@ -136,89 +141,43 @@ final class NoiseRenderCoordinator implements AutoCloseable {
return;
}
latestRevision.incrementAndGet();
- cancelActiveRender();
+ latestRequested.set(null);
+ workers.shutdownNow();
}
- private void renderGeneration(RenderGeneration generation) {
- Request request = generation.request();
- if (!isCurrent(request)) {
- generation.close();
- return;
- }
- listener.onRenderStarted(request);
+ // Bands of a frame that was replaced are skipped, so a new frame only waits for the rows already being drawn.
+ private void renderBand(RenderGeneration generation, int bandIndex) {
try {
- Result result = render(generation);
- if (result != null && isCurrent(request)) {
- listener.onRenderCompleted(result);
+ if (isCurrent(generation)) {
+ renderRows(generation, bandIndex);
}
} catch (Throwable error) {
- if (isCurrent(request)) {
- listener.onRenderFailed(request, error);
- }
+ generation.fail(error);
} finally {
- latestRequested.compareAndSet(generation, null);
- generation.close();
+ finishBand(generation);
}
}
- private Result render(RenderGeneration generation) throws InterruptedException {
+ private void finishBand(RenderGeneration generation) {
+ if (!generation.completeBand() || !isCurrent(generation)) {
+ return;
+ }
+ Throwable failure = generation.failure();
+ if (failure != null) {
+ listener.onRenderFailed(generation.request(), failure);
+ return;
+ }
+ listener.onRenderCompleted(generation.result());
+ }
+
+ private void renderRows(RenderGeneration generation, int bandIndex) {
Request request = generation.request();
- long started = System.nanoTime();
- int outputWidth = Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
- int outputHeight = Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
- BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
- int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
- int bandCount = Math.min(workerCount, outputHeight);
- BandStats[] bandStats = new BandStats[bandCount];
- CountDownLatch completion = new CountDownLatch(bandCount);
- AtomicReference failure = new AtomicReference<>();
- for (int band = 0; band < bandCount; band++) {
- bandStats[band] = new BandStats();
- }
- for (int band = 0; band < bandCount; band++) {
- int bandIndex = band;
- Runnable task = () -> {
- try {
- renderBand(request, image, pixels, bandIndex, bandCount, bandStats[bandIndex]);
- } catch (Throwable error) {
- failure.compareAndSet(null, error);
- } finally {
- completion.countDown();
- }
- };
- try {
- generation.executor().execute(task);
- } catch (RejectedExecutionException exception) {
- failure.compareAndSet(null, exception);
- completion.countDown();
- }
- }
- completion.await();
- Throwable renderFailure = failure.get();
- if (renderFailure != null) {
- if (renderFailure instanceof RuntimeException runtimeException) {
- throw runtimeException;
- }
- if (renderFailure instanceof Error error) {
- throw error;
- }
- throw new IllegalStateException("Noise rendering failed", renderFailure);
- }
- if (!isCurrent(request)) {
- return null;
- }
- RenderStats combined = combine(bandStats);
- double milliseconds = (System.nanoTime() - started) / 1_000_000D;
- return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
- combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
- }
-
- private void renderBand(Request request, BufferedImage image, int[] pixels, int bandIndex, int bandCount,
- BandStats stats) {
- int outputWidth = image.getWidth();
- int outputHeight = image.getHeight();
- int fromY = (outputHeight * bandIndex) / bandCount;
- int toY = (outputHeight * (bandIndex + 1)) / bandCount;
+ int outputWidth = generation.outputWidth();
+ int outputHeight = generation.outputHeight();
+ int[] pixels = generation.pixels();
+ BandStats stats = generation.bandStats(bandIndex);
+ int fromY = (outputHeight * bandIndex) / generation.bandCount();
+ int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
double sampleOffset = request.sampleStep() * 0.5D;
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
@@ -232,7 +191,7 @@ final class NoiseRenderCoordinator implements AutoCloseable {
int pixelIndex = y * outputWidth;
boolean centerRow = y == outputHeight / 2;
for (int x = 0; x < outputWidth; x++) {
- if ((x & 31) == 0 && (!isCurrent(request) || Thread.currentThread().isInterrupted())) {
+ if ((x & 31) == 0 && (!isCurrent(generation) || Thread.currentThread().isInterrupted())) {
return;
}
double value = request.sampler().noise(worldX, worldZ);
@@ -249,36 +208,18 @@ final class NoiseRenderCoordinator implements AutoCloseable {
}
}
- private boolean isCurrent(Request request) {
+ private boolean isCurrent(RenderGeneration generation) {
return !closed.get()
- && latestRequested.get() != null
- && latestRequested.get().request() == request
- && request.revision() >= latestRevision.get();
+ && latestRequested.get() == generation
+ && generation.request().revision() >= latestRevision.get();
}
- private void cancelActiveRender() {
- RenderGeneration generation = latestRequested.getAndSet(null);
- if (generation != null) {
- generation.close();
- }
+ private static int outputWidth(Request request) {
+ return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
}
- private RenderGeneration createGeneration(Request request) {
- ThreadPoolExecutor executor = new ThreadPoolExecutor(
- workerCount,
- workerCount,
- 0L,
- TimeUnit.MILLISECONDS,
- new ArrayBlockingQueue<>(Math.max(1, workerCount)),
- workerThreadFactory,
- new ThreadPoolExecutor.AbortPolicy()
- );
- GuiHost.interruptOnPluginShutdown(executor);
- RenderGeneration generation = new RenderGeneration(request, executor);
- Thread actualRunner = coordinatorThreadFactory.newThread(() -> renderGeneration(generation));
- generation.setRunner(actualRunner);
- GuiHost.interruptOnPluginShutdown(actualRunner);
- return generation;
+ private static int outputHeight(Request request) {
+ return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
}
private static RenderStats combine(BandStats[] bands) {
@@ -333,36 +274,71 @@ final class NoiseRenderCoordinator implements AutoCloseable {
private static final class RenderGeneration {
private final Request request;
- private final ThreadPoolExecutor executor;
- private volatile Thread runner;
+ private final long startedNanos = System.nanoTime();
+ private final int outputWidth;
+ private final int outputHeight;
+ private final int[] pixels;
+ private final BandStats[] bandStats;
+ private final AtomicInteger remainingBands;
+ private final AtomicReference failure = new AtomicReference<>();
- private RenderGeneration(Request request, ThreadPoolExecutor executor) {
- this.request = Objects.requireNonNull(request, "request");
- this.executor = Objects.requireNonNull(executor, "executor");
+ private RenderGeneration(Request request, int bandCount) {
+ this.request = request;
+ this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
+ this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
+ this.pixels = new int[outputWidth * outputHeight];
+ this.bandStats = new BandStats[Math.max(1, bandCount)];
+ for (int band = 0; band < bandStats.length; band++) {
+ bandStats[band] = new BandStats();
+ }
+ this.remainingBands = new AtomicInteger(bandStats.length);
}
private Request request() {
return request;
}
- private ThreadPoolExecutor executor() {
- return executor;
+ private int bandCount() {
+ return bandStats.length;
}
- private void setRunner(Thread runner) {
- this.runner = Objects.requireNonNull(runner, "runner");
+ private int outputWidth() {
+ return outputWidth;
}
- private void start() {
- runner.start();
+ private int outputHeight() {
+ return outputHeight;
}
- private void close() {
- Thread activeRunner = runner;
- if (activeRunner != null) {
- activeRunner.interrupt();
- }
- executor.shutdownNow();
+ private int[] pixels() {
+ return pixels;
+ }
+
+ private BandStats bandStats(int band) {
+ return bandStats[band];
+ }
+
+ private void fail(Throwable error) {
+ failure.compareAndSet(null, error);
+ }
+
+ private Throwable failure() {
+ return failure.get();
+ }
+
+ // True for the band that finishes last, which is the one that publishes the frame.
+ private boolean completeBand() {
+ return remainingBands.decrementAndGet() == 0;
+ }
+
+ // Copied into a fresh image instead of drawing into its buffer, so Java2D can keep the finished frame on the GPU.
+ private Result result() {
+ BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
+ image.getRaster().setDataElements(0, 0, outputWidth, outputHeight, pixels);
+ RenderStats combined = combine(bandStats);
+ double milliseconds = (System.nanoTime() - startedNanos) / 1_000_000D;
+ return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
+ combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java
index 6e87dd690..1d499f240 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java
@@ -24,13 +24,11 @@ import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
-import javax.swing.JScrollPane;
import javax.swing.JToggleButton;
import javax.swing.SwingConstants;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
-import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
@@ -40,30 +38,19 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.HEADER_FONT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.LEGEND_FONT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.PANEL_BACKGROUND;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.SIDEBAR_BACKGROUND;
final class PaletteInspectorPanel extends JPanel {
private static final long serialVersionUID = 4473102938518712001L;
private static final int PANEL_WIDTH = 310;
- private static final Color WARNING = new Color(255, 196, 92);
- private static final Color HISTOGRAM_BAR = new Color(151, 160, 178, 170);
+ private static final Color HISTOGRAM_BAR = new Color(0x9D, 0xA0, 0xA8, 150);
private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70);
private final Listener listener;
private final JLabel titleLabel = new JLabel();
private final JLabel detailLabel = new JLabel();
private final JLabel depthLabel = new JLabel("", SwingConstants.CENTER);
- private final JButton shallowerButton = ExplorerStyle.button("‹");
- private final JButton deeperButton = ExplorerStyle.button("›");
+ private final JButton shallowerButton = new JButton("‹");
+ private final JButton deeperButton = new JButton("›");
private final JPanel depthRow = new JPanel(new BorderLayout(6, 0));
private final StatisticsView statisticsView = new StatisticsView();
private PaletteLayerScale.View selectedView = PaletteLayerScale.View.BLOCKS;
@@ -74,13 +61,9 @@ final class PaletteInspectorPanel extends JPanel {
super(new BorderLayout());
this.listener = listener;
setPreferredSize(new Dimension(PANEL_WIDTH, 0));
- setBackground(SIDEBAR_BACKGROUND);
- setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, BORDER));
+ setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
add(buildControls(), BorderLayout.NORTH);
- JScrollPane statisticsScroll = new JScrollPane(statisticsView);
- statisticsScroll.setBorder(BorderFactory.createEmptyBorder());
- statisticsScroll.getVerticalScrollBar().setUnitIncrement(18);
- add(statisticsScroll, BorderLayout.CENTER);
+ add(StudioToolbar.scroll(statisticsView), BorderLayout.CENTER);
setVisible(false);
}
@@ -109,15 +92,13 @@ final class PaletteInspectorPanel extends JPanel {
private JPanel buildControls() {
JPanel controls = new JPanel(new GridLayout(0, 1, 0, 6));
- controls.setBackground(PANEL_BACKGROUND);
controls.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
+ BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
BorderFactory.createEmptyBorder(12, 14, 12, 14)
));
- titleLabel.setForeground(PRIMARY_TEXT);
- titleLabel.setFont(ITEM_FONT.deriveFont(Font.BOLD, 14F));
- detailLabel.setForeground(SECONDARY_TEXT);
- detailLabel.setFont(ITEM_FONT);
+ titleLabel.setFont(StudioTheme.titleFont());
+ detailLabel.setForeground(StudioTheme.SECONDARY_TEXT);
+ detailLabel.setFont(StudioTheme.smallFont());
controls.add(titleLabel);
controls.add(detailLabel);
@@ -133,7 +114,8 @@ final class PaletteInspectorPanel extends JPanel {
row.setOpaque(false);
ButtonGroup group = new ButtonGroup();
for (PaletteLayerScale.View view : PaletteLayerScale.View.values()) {
- JToggleButton button = ExplorerStyle.toggleButton(view.toString());
+ JToggleButton button = new JToggleButton(view.toString());
+ button.setFocusable(false);
button.setSelected(view == selectedView);
button.addActionListener(event -> {
selectedView = view;
@@ -147,8 +129,8 @@ final class PaletteInspectorPanel extends JPanel {
private JPanel buildDepthRow() {
depthRow.setOpaque(false);
- depthLabel.setForeground(PRIMARY_TEXT);
- depthLabel.setFont(ITEM_FONT);
+ shallowerButton.setToolTipText("Show the block one higher up");
+ deeperButton.setToolTipText("Show the block one further down");
shallowerButton.addActionListener(event -> changeDepth(-1));
deeperButton.addActionListener(event -> changeDepth(1));
depthRow.add(shallowerButton, BorderLayout.WEST);
@@ -174,9 +156,9 @@ final class PaletteInspectorPanel extends JPanel {
}
private static JLabel sectionLabel(String text) {
- JLabel label = new JLabel(text);
- label.setForeground(SECONDARY_TEXT);
- label.setFont(HEADER_FONT);
+ JLabel label = new JLabel(text.toUpperCase(Locale.ROOT));
+ label.setForeground(StudioTheme.MUTED_TEXT);
+ label.setFont(StudioTheme.sectionFont());
return label;
}
@@ -227,7 +209,7 @@ final class PaletteInspectorPanel extends JPanel {
private StatisticsView() {
setOpaque(true);
- setBackground(SIDEBAR_BACKGROUND);
+ setBackground(StudioTheme.PANEL_BACKGROUND);
}
private void show(PaletteLayerPreview nextPreview) {
@@ -248,7 +230,7 @@ final class PaletteInspectorPanel extends JPanel {
return new Dimension(PANEL_WIDTH - 20, 0);
}
int width = Math.max(getWidth(), PANEL_WIDTH - 20) - MARGIN * 2;
- FontMetrics noteMetrics = getFontMetrics(LEGEND_FONT);
+ FontMetrics noteMetrics = getFontMetrics(StudioTheme.smallFont());
int noteLines = 0;
for (String note : notes()) {
noteLines += wrap(note, noteMetrics, width).size() + 1;
@@ -265,7 +247,7 @@ final class PaletteInspectorPanel extends JPanel {
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
- canvas.setColor(SIDEBAR_BACKGROUND);
+ canvas.setColor(StudioTheme.PANEL_BACKGROUND);
canvas.fillRect(0, 0, getWidth(), getHeight());
if (preview == null) {
return;
@@ -280,7 +262,7 @@ final class PaletteInspectorPanel extends JPanel {
}
private int drawValueSpread(Graphics2D canvas, int x, int y, int width) {
- drawSectionTitle(canvas, "NOISE VALUES THIS LAYER PRODUCES", x, y + 10);
+ drawSectionTitle(canvas, "Noise values this layer produces", x, y + 10);
int histogramTop = y + 18;
long[] histogram = preview.histogram();
long peak = 1L;
@@ -310,8 +292,8 @@ final class PaletteInspectorPanel extends JPanel {
}
}
- canvas.setFont(LEGEND_FONT);
- canvas.setColor(SECONDARY_TEXT);
+ canvas.setFont(StudioTheme.smallFont());
+ canvas.setColor(StudioTheme.SECONDARY_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
int labelY = bandTop + BAND_HEIGHT + 13;
canvas.drawString("0", x, labelY);
@@ -323,19 +305,19 @@ final class PaletteInspectorPanel extends JPanel {
private int drawBlockTable(Graphics2D canvas, int x, int y, int width) {
int actualRight = x + width;
int weightRight = actualRight - ACTUAL_COLUMN_WIDTH;
- drawSectionTitle(canvas, "BLOCKS", x, y);
+ drawSectionTitle(canvas, "Blocks", x, y);
drawRightAligned(canvas, "weight", weightRight, y);
drawRightAligned(canvas, "actual", actualRight, y);
int rowY = y + ROW_HEIGHT;
for (PaletteLayerPreview.Entry entry : preview.entries()) {
drawSwatch(canvas, entry, x, rowY - SWATCH_SIZE + 2);
- canvas.setFont(ITEM_FONT);
- canvas.setColor(entry.found() ? PRIMARY_TEXT : NoiseExplorerGUI.ERROR);
+ canvas.setFont(StudioTheme.font());
+ canvas.setColor(entry.found() ? StudioTheme.TEXT : StudioTheme.ERROR);
String name = entry.found() ? shortBlockName(entry.block()) : shortBlockName(entry.block()) + " (not found)";
int nameLeft = x + SWATCH_SIZE + 7;
canvas.drawString(fit(canvas, name, weightRight - WEIGHT_COLUMN_WIDTH - nameLeft), nameLeft, rowY);
- canvas.setColor(SECONDARY_TEXT);
+ canvas.setColor(StudioTheme.SECONDARY_TEXT);
drawRightAligned(canvas, percent(entry.weightShare()), weightRight, rowY);
canvas.setColor(actualColor(entry));
drawRightAligned(canvas, actualText(entry), actualRight, rowY);
@@ -354,8 +336,8 @@ final class PaletteInspectorPanel extends JPanel {
}
private void drawNotes(Graphics2D canvas, int x, int y, int width) {
- canvas.setFont(LEGEND_FONT);
- canvas.setColor(SECONDARY_TEXT);
+ canvas.setFont(StudioTheme.smallFont());
+ canvas.setColor(StudioTheme.SECONDARY_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
int lineY = y;
for (String note : notes()) {
@@ -401,13 +383,13 @@ final class PaletteInspectorPanel extends JPanel {
}
private static Color actualColor(PaletteLayerPreview.Entry entry) {
- return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? WARNING : PRIMARY_TEXT;
+ return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? StudioTheme.WARNING : StudioTheme.TEXT;
}
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) {
- canvas.setFont(HEADER_FONT);
- canvas.setColor(ACCENT);
- canvas.drawString(title, x, y);
+ canvas.setFont(StudioTheme.sectionFont());
+ canvas.setColor(StudioTheme.MUTED_TEXT);
+ canvas.drawString(title.toUpperCase(Locale.ROOT), x, y);
}
private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) {
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java
index df3d2808e..7a88b2706 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java
@@ -30,12 +30,10 @@ import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
-import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.JTree;
import javax.swing.ListSelectionModel;
-import javax.swing.ScrollPaneConstants;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.tree.DefaultMutableTreeNode;
@@ -53,21 +51,13 @@ import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.HEADER_FONT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.PANEL_BACKGROUND;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
-import static com.volmit.iris.core.gui.NoiseExplorerGUI.SIDEBAR_BACKGROUND;
final class PaletteNavigator extends JPanel {
private static final long serialVersionUID = -6402313718430917284L;
private static final String HERE_CARD = "here";
private static final String OVERVIEW_CARD = "overview";
- private static final String DETAIL_COLOR = "#8890A0";
+ private static final String DETAIL_COLOR = StudioTheme.SECONDARY_TEXT_HTML;
+ private static final int MESSAGE_WRAP_WIDTH_PIXELS = 190;
private final Listener listener;
private final CardLayout cards = new CardLayout();
@@ -84,11 +74,11 @@ final class PaletteNavigator extends JPanel {
private String selectedBiome;
private int selectedLayer;
private boolean updatingSelection;
+ private boolean untracked;
PaletteNavigator(Listener listener) {
super(new BorderLayout());
this.listener = listener;
- setBackground(SIDEBAR_BACKGROUND);
cardPanel.add(buildHereCard(), HERE_CARD);
cardPanel.add(buildOverviewCard(), OVERVIEW_CARD);
add(buildTabBar(), BorderLayout.NORTH);
@@ -107,6 +97,12 @@ final class PaletteNavigator extends JPanel {
refreshHere();
}
+ // Without a player there is nowhere to call "here", so the Here tab explains how to get one instead.
+ void showUntracked() {
+ untracked = true;
+ refreshHere();
+ }
+
void markSelected(String biomeKey, int layer) {
selectedBiome = biomeKey;
selectedLayer = layer;
@@ -120,9 +116,8 @@ final class PaletteNavigator extends JPanel {
}
private JPanel buildTabBar() {
- JPanel bar = new JPanel(new GridLayout(1, 2, 6, 0));
- bar.setBackground(SIDEBAR_BACKGROUND);
- bar.setBorder(BorderFactory.createEmptyBorder(0, 12, 8, 12));
+ JPanel bar = new JPanel(new GridLayout(1, 2, 0, 0));
+ bar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR));
ButtonGroup group = new ButtonGroup();
JToggleButton here = createTab("Here", HERE_CARD, group);
JToggleButton overview = createTab("Overview", OVERVIEW_CARD, group);
@@ -133,7 +128,7 @@ final class PaletteNavigator extends JPanel {
}
private JToggleButton createTab(String text, String card, ButtonGroup group) {
- JToggleButton tab = ExplorerStyle.toggleButton(text);
+ JToggleButton tab = StudioToolbar.tab(text);
tab.addActionListener(event -> cards.show(cardPanel, card));
group.add(tab);
return tab;
@@ -141,18 +136,10 @@ final class PaletteNavigator extends JPanel {
private JPanel buildHereCard() {
JPanel card = new JPanel(new BorderLayout());
- card.setBackground(SIDEBAR_BACKGROUND);
JPanel location = new JPanel(new GridLayout(0, 1, 0, 3));
- location.setBackground(PANEL_BACKGROUND);
- location.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
- BorderFactory.createEmptyBorder(9, 12, 9, 12)
- ));
- regionLabel.setForeground(PRIMARY_TEXT);
- regionLabel.setFont(ITEM_FONT);
+ location.setBorder(BorderFactory.createEmptyBorder(9, 12, 5, 12));
location.add(regionLabel);
- hereList.setBackground(SIDEBAR_BACKGROUND);
hereList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
hereList.setCellRenderer(new HereRowRenderer());
hereList.addListSelectionListener(event -> {
@@ -162,23 +149,13 @@ final class PaletteNavigator extends JPanel {
}
});
card.add(location, BorderLayout.NORTH);
- card.add(scroll(hereList), BorderLayout.CENTER);
+ card.add(StudioToolbar.scroll(hereList), BorderLayout.CENTER);
return card;
}
private JPanel buildOverviewCard() {
JPanel card = new JPanel(new BorderLayout());
- card.setBackground(SIDEBAR_BACKGROUND);
- searchField.setBackground(FIELD_BACKGROUND);
- searchField.setForeground(PRIMARY_TEXT);
- searchField.setCaretColor(PRIMARY_TEXT);
- searchField.setFont(ITEM_FONT);
- searchField.putClientProperty("JTextField.placeholderText", "Search biomes or blocks...");
- searchField.setToolTipText("Search biomes or blocks");
- searchField.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
- BorderFactory.createEmptyBorder(8, 12, 8, 12)
- ));
+ StudioToolbar.makeSearchField(searchField, "Search biomes or blocks...");
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
@@ -198,8 +175,7 @@ final class PaletteNavigator extends JPanel {
overviewTree.setRootVisible(false);
overviewTree.setShowsRootHandles(true);
- overviewTree.setBackground(SIDEBAR_BACKGROUND);
- overviewTree.setRowHeight(22);
+ overviewTree.setRowHeight(24);
overviewTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
overviewTree.setCellRenderer(new OverviewRenderer());
overviewTree.addTreeSelectionListener(event -> {
@@ -207,20 +183,11 @@ final class PaletteNavigator extends JPanel {
chooseBiome(node.biomeKey(), Math.max(0, node.layer()));
}
});
- card.add(searchField, BorderLayout.NORTH);
- card.add(scroll(overviewTree), BorderLayout.CENTER);
+ card.add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
+ card.add(StudioToolbar.scroll(overviewTree), BorderLayout.CENTER);
return card;
}
- private static JScrollPane scroll(Component content) {
- JScrollPane scrollPane = new JScrollPane(content);
- scrollPane.setBorder(BorderFactory.createEmptyBorder());
- scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
- scrollPane.getViewport().setBackground(SIDEBAR_BACKGROUND);
- scrollPane.getVerticalScrollBar().setUnitIncrement(18);
- return scrollPane;
- }
-
private void chooseBiome(String biomeKey, int preferredLayer) {
BiomeEntry biome = catalog == null ? null : catalog.biome(biomeKey);
if (biome == null || biome.layerSummaries().isEmpty()) {
@@ -233,6 +200,10 @@ final class PaletteNavigator extends JPanel {
updatingSelection = true;
try {
hereRows.clear();
+ if (untracked) {
+ showHereMessage("Run /iris std pal in-game to see the palettes where you stand.");
+ return;
+ }
if (location == null || catalog == null) {
showHereMessage("Finding where you are...");
return;
@@ -249,7 +220,7 @@ final class PaletteNavigator extends JPanel {
}
private void showHereMessage(String message) {
- regionLabel.setText(message);
+ regionLabel.setText("" + message + "");
}
private void showHereRows(PlayerLocation here) {
@@ -445,11 +416,12 @@ final class PaletteNavigator extends JPanel {
public Component getListCellRendererComponent(JList> list, Object value, int index, boolean selected, boolean focus) {
HereRow row = (HereRow) value;
boolean selectable = row.biomeKey() != null;
- super.getListCellRendererComponent(list, row.text(), index, selected && selectable, false);
- setOpaque(true);
- setFont(row.header() ? HEADER_FONT : ITEM_FONT);
- setForeground(row.header() ? ACCENT : selected ? PRIMARY_TEXT : SECONDARY_TEXT);
- setBackground(selected && selectable ? FIELD_BACKGROUND.brighter() : SIDEBAR_BACKGROUND);
+ super.getListCellRendererComponent(list, row.header() ? row.text().toUpperCase(Locale.ROOT) : row.text(), index,
+ selected && selectable, false);
+ setFont(row.header() ? StudioTheme.sectionFont() : StudioTheme.font());
+ if (row.header()) {
+ setForeground(StudioTheme.MUTED_TEXT);
+ }
int indent = row.header() ? HEADER_INDENT : row.layer() >= 0 ? LAYER_INDENT : BIOME_INDENT;
setBorder(BorderFactory.createEmptyBorder(row.header() ? 11 : 4, indent, 4, 10));
return this;
@@ -463,11 +435,6 @@ final class PaletteNavigator extends JPanel {
setLeafIcon(null);
setOpenIcon(null);
setClosedIcon(null);
- setBackgroundNonSelectionColor(SIDEBAR_BACKGROUND);
- setBackgroundSelectionColor(FIELD_BACKGROUND.brighter());
- setBorderSelectionColor(null);
- setTextNonSelectionColor(SECONDARY_TEXT);
- setTextSelectionColor(PRIMARY_TEXT);
}
@Override
@@ -476,7 +443,7 @@ final class PaletteNavigator extends JPanel {
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
String text = userObject instanceof OverviewNode node ? node.html() : "";
super.getTreeCellRendererComponent(tree, text, selected, expanded, leaf, row, false);
- setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? HEADER_FONT : ITEM_FONT);
+ setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? StudioTheme.boldFont() : StudioTheme.font());
return this;
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/VisionGUI.java b/core/src/main/java/com/volmit/iris/core/gui/VisionGUI.java
deleted file mode 100644
index 7a7c39a20..000000000
--- a/core/src/main/java/com/volmit/iris/core/gui/VisionGUI.java
+++ /dev/null
@@ -1,1158 +0,0 @@
-/*
- * Iris is a World Generator for Minecraft Bukkit Servers
- * Copyright (c) 2022 Arcane Arts (Volmit Software)
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.volmit.iris.core.gui;
-
-import com.volmit.iris.Iris;
-import com.volmit.iris.core.gui.components.IrisRenderer;
-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.BorderFactory;
-import javax.swing.DefaultListCellRenderer;
-import javax.swing.JButton;
-import javax.swing.JComboBox;
-import javax.swing.JFrame;
-import javax.swing.JLabel;
-import javax.swing.JList;
-import javax.swing.JPanel;
-import javax.swing.JToggleButton;
-import javax.swing.Timer;
-import javax.swing.event.MouseInputListener;
-import java.awt.BorderLayout;
-import java.awt.Color;
-import java.awt.Component;
-import java.awt.Dimension;
-import java.awt.EventQueue;
-import java.awt.FlowLayout;
-import java.awt.Font;
-import java.awt.Graphics;
-import java.awt.Graphics2D;
-import java.awt.Point;
-import java.awt.RenderingHints;
-import java.awt.event.ComponentAdapter;
-import java.awt.event.ComponentEvent;
-import java.awt.event.KeyEvent;
-import java.awt.event.KeyListener;
-import java.awt.event.MouseEvent;
-import java.awt.event.MouseMotionListener;
-import java.awt.event.MouseWheelEvent;
-import java.awt.event.MouseWheelListener;
-import java.awt.event.WindowAdapter;
-import java.awt.event.WindowEvent;
-import java.awt.geom.RoundRectangle2D;
-import java.awt.image.BufferedImage;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.UUID;
-
-public final class VisionGUI extends JPanel implements MouseWheelListener, KeyListener, MouseMotionListener, MouseInputListener {
- private static final long serialVersionUID = 2094606939770332040L;
-
- private static final Color BACKGROUND = new Color(15, 17, 22);
- private static final Color TOOLBAR_BACKGROUND = new Color(23, 25, 31);
- private static final Color CONTROL_BACKGROUND = new Color(31, 34, 42);
- private static final Color CONTROL_SELECTED = new Color(50, 64, 91);
- private static final Color CARD_BACKGROUND = new Color(25, 28, 35, 232);
- private static final Color CARD_BORDER = new Color(69, 74, 88, 210);
- private static final Color TEXT_PRIMARY = new Color(229, 231, 237);
- private static final Color TEXT_SECONDARY = new Color(155, 161, 174);
- private static final Color ACCENT = new Color(96, 146, 244);
- private static final Color PLAYER_COLOR = new Color(81, 201, 128);
- private static final Color MOB_COLOR = new Color(227, 94, 98);
- private static final Color STATUS_BACKGROUND = new Color(21, 23, 29, 246);
- private static final Color GRID_MINOR = new Color(255, 255, 255, 18);
- private static final Color GRID_MAJOR = new Color(255, 255, 255, 38);
- private static final Color TILE_EMPTY_A = new Color(24, 27, 34);
- private static final Color TILE_EMPTY_B = new Color(27, 30, 38);
- private static final Font STATUS_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12);
- private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12);
- private static final Font BODY_BOLD_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 12);
- private static final Font TOOLBAR_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12);
- private static final Font KEY_FONT = new Font(Font.MONOSPACED, Font.BOLD, 12);
- private static final int STATUS_HEIGHT = 28;
- private static final int PROGRESS_HEIGHT = 3;
- private static final int CARD_RADIUS = 10;
- private static final int CARD_PADDING = 12;
- 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 final JFrame hostFrame;
- private final UUID openerId;
- private final VisionRenderController controller;
- private final Runnable hotloadHook;
- private final Timer resizeTimer;
- private final Timer dragRenderTimer;
- private final Timer zoomRenderTimer;
- private final Timer hoverTimer;
- private final Timer markerTimer;
- private final Timer notificationTimer;
- private final Map notifications;
-
- private Engine engine;
- private IrisRenderer renderer;
- private VisionOverlay overlay;
- private VisionRenderController.Frame renderFrame;
- private JComboBox modeSelector;
- private JToggleButton gridToggle;
- private JToggleButton entitiesToggle;
- private JToggleButton followToggle;
- private RenderType currentType;
- private List players;
- private List entities;
- private HoverInfo hoverInfo;
- private Point cursor;
- private double centerX;
- private double centerZ;
- private double dragX;
- private double dragY;
- private double heightMaximum;
- private double fluidHeight;
- private long contentRevision;
- private long previousPaintStartedNanos;
- private double blocksPerPixel;
- private int paintCadenceFps;
- private boolean grid;
- private boolean entitiesVisible;
- private boolean follow;
- private boolean help;
- private boolean debug;
- private boolean detailedHover;
- private boolean controlsUpdating;
- private boolean closed;
-
- private VisionGUI(JFrame hostFrame, Engine engine, UUID openerId) {
- this.hostFrame = Objects.requireNonNull(hostFrame, "hostFrame");
- this.engine = Objects.requireNonNull(engine, "engine");
- this.openerId = openerId;
- this.renderer = new IrisRenderer(engine);
- this.overlay = new VisionOverlay(engine, openerId);
- this.controller = new VisionRenderController(this::repaint);
- this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent);
- this.notifications = new LinkedHashMap<>();
- this.players = List.of();
- this.entities = List.of();
- this.entitiesVisible = false;
- this.currentType = RenderType.BIOME;
- this.blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL;
- this.contentRevision = 1L;
- captureHeightRange();
-
- setBackground(BACKGROUND);
- setFocusable(true);
- addMouseWheelListener(this);
- addMouseMotionListener(this);
- addMouseListener(this);
- hostFrame.addKeyListener(this);
-
- this.resizeTimer = singleShotTimer(90, this::requestRender);
- this.dragRenderTimer = singleShotTimer(35, this::requestRender);
- this.zoomRenderTimer = singleShotTimer(110, this::requestRender);
- this.hoverTimer = singleShotTimer(90, this::requestHover);
- this.markerTimer = new Timer(650, event -> refreshMarkers());
- this.markerTimer.setCoalesce(true);
- this.notificationTimer = new Timer(200, event -> expireNotifications());
- this.notificationTimer.setCoalesce(true);
-
- addComponentListener(new ComponentAdapter() {
- @Override
- public void componentResized(ComponentEvent event) {
- resizeTimer.restart();
- repaint();
- }
- });
- hostFrame.addWindowListener(new WindowAdapter() {
- @Override
- public void windowClosing(WindowEvent event) {
- close();
- }
-
- @Override
- public void windowClosed(WindowEvent event) {
- close();
- }
- });
- GuiHost.registerHotloadHook(hotloadHook);
- markerTimer.start();
- notificationTimer.start();
- }
-
- public static void launch(Engine engine, UUID openerId) {
- EventQueue.invokeLater(() -> createAndShowGUI(engine, openerId));
- }
-
- private static void createAndShowGUI(Engine engine, UUID openerId) {
- JFrame frame = GuiHost.createFrame("Iris Vision");
- VisionGUI vision = new VisionGUI(frame, engine, openerId);
- frame.getContentPane().setBackground(BACKGROUND);
- frame.setLayout(new BorderLayout());
- frame.add(buildToolbar(vision), BorderLayout.NORTH);
- frame.add(vision, BorderLayout.CENTER);
- frame.setSize(1440, 820);
- frame.setMinimumSize(new Dimension(720, 500));
- frame.setLocationRelativeTo(null);
- frame.setVisible(true);
- EventQueue.invokeLater(() -> {
- vision.requestFocusInWindow();
- vision.refreshMarkers();
- vision.requestRender();
- });
- }
-
- private static JPanel buildToolbar(VisionGUI vision) {
- JPanel toolbar = new JPanel(new BorderLayout());
- toolbar.setBackground(TOOLBAR_BACKGROUND);
- toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, CARD_BORDER));
-
- JPanel leading = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 5));
- leading.setOpaque(false);
- JLabel viewLabel = new JLabel("View:");
- viewLabel.setFont(BODY_BOLD_FONT);
- viewLabel.setForeground(TEXT_SECONDARY);
- leading.add(viewLabel);
-
- JComboBox selector = new JComboBox<>(RenderType.values());
- selector.setSelectedItem(vision.currentType);
- selector.setFont(TOOLBAR_FONT);
- selector.setForeground(TEXT_PRIMARY);
- selector.setBackground(CONTROL_BACKGROUND);
- selector.setFocusable(false);
- selector.setRenderer(new ModeCellRenderer());
- selector.addActionListener(event -> {
- if (!vision.controlsUpdating && selector.getSelectedItem() instanceof RenderType type) {
- vision.setRenderType(type);
- }
- });
- vision.modeSelector = selector;
- leading.add(selector);
-
- JButton refresh = new JButton("Refresh tiles");
- styleButton(refresh);
- refresh.addActionListener(event -> vision.refreshContent());
- leading.add(refresh);
- toolbar.add(leading, BorderLayout.WEST);
-
- JPanel trailing = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 5));
- trailing.setOpaque(false);
- vision.gridToggle = createToggle("Grid", vision.grid);
- vision.gridToggle.addActionListener(event -> vision.toggleGrid());
- trailing.add(vision.gridToggle);
- vision.entitiesToggle = createToggle(
- "Entities",
- vision.entitiesVisible
- );
- vision.entitiesToggle.addActionListener(event -> vision.toggleEntities());
- trailing.add(vision.entitiesToggle);
- vision.followToggle = createToggle("Follow", vision.follow);
- vision.followToggle.addActionListener(event -> vision.toggleFollow());
- trailing.add(vision.followToggle);
- toolbar.add(trailing, BorderLayout.EAST);
- return toolbar;
- }
-
- private static void styleButton(JButton button) {
- button.setFont(TOOLBAR_FONT);
- button.setForeground(TEXT_PRIMARY);
- button.setBackground(CONTROL_BACKGROUND);
- button.setFocusable(false);
- button.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createLineBorder(CARD_BORDER),
- BorderFactory.createEmptyBorder(4, 9, 4, 9)
- ));
- }
-
- private static JToggleButton createToggle(String text, boolean selected) {
- JToggleButton toggle = new JToggleButton(text, selected);
- toggle.setFont(TOOLBAR_FONT);
- toggle.setForeground(selected ? TEXT_PRIMARY : TEXT_SECONDARY);
- toggle.setBackground(selected ? CONTROL_SELECTED : CONTROL_BACKGROUND);
- toggle.setFocusable(false);
- toggle.setOpaque(true);
- toggle.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createLineBorder(selected ? ACCENT : CARD_BORDER),
- BorderFactory.createEmptyBorder(4, 9, 4, 9)
- ));
- toggle.addChangeListener(event -> updateToggleStyle(toggle));
- return toggle;
- }
-
- private static void updateToggleStyle(JToggleButton toggle) {
- toggle.setForeground(toggle.isSelected() ? TEXT_PRIMARY : TEXT_SECONDARY);
- toggle.setBackground(toggle.isSelected() ? CONTROL_SELECTED : CONTROL_BACKGROUND);
- toggle.setBorder(BorderFactory.createCompoundBorder(
- BorderFactory.createLineBorder(toggle.isSelected() ? ACCENT : CARD_BORDER),
- BorderFactory.createEmptyBorder(4, 9, 4, 9)
- ));
- }
-
- private static Timer singleShotTimer(int delay, Runnable task) {
- Timer timer = new Timer(delay, event -> task.run());
- timer.setRepeats(false);
- timer.setCoalesce(true);
- return timer;
- }
-
- @Override
- protected void paintComponent(Graphics graphics) {
- long started = System.nanoTime();
- if (previousPaintStartedNanos > 0L) {
- long interval = started - previousPaintStartedNanos;
- paintCadenceFps = interval > 2_000_000_000L
- ? 0
- : (int) Math.min(240L, Math.round(1_000_000_000D / Math.max(1L, interval)));
- }
- previousPaintStartedNanos = started;
- 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);
- canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
- renderTiles(canvas);
- if (grid) {
- renderGrid(canvas);
- }
- renderMarkers(canvas);
- renderLegend(canvas);
- if (help) {
- renderHelp(canvas);
- } else if (debug) {
- renderDebug(canvas);
- }
- renderHover(canvas);
- renderNotifications(canvas);
- renderStatus(canvas);
- } catch (Throwable error) {
- Iris.debug("Vision paint failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
- } finally {
- canvas.dispose();
- }
- }
-
- private void renderTiles(Graphics2D canvas) {
- VisionRenderController.Frame frame = renderFrame;
- if (frame == null) {
- canvas.setColor(BACKGROUND);
- canvas.fillRect(0, 0, getWidth(), getHeight());
- return;
- }
-
- canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
- double currentBlocksPerPixel = blocksPerPixel();
- double frameTileSpan = VisionRenderController.TILE_PIXELS * frame.spec().blocksPerPixel();
- for (VisionRenderController.VisibleTile tile : frame.tiles()) {
- double worldX = tile.tileX() * frameTileSpan;
- double worldZ = tile.tileZ() * frameTileSpan;
- int screenX = (int) Math.round(getWidth() * 0.5D + (worldX - centerX) / currentBlocksPerPixel);
- int screenY = (int) Math.round(getHeight() * 0.5D + (worldZ - centerZ) / currentBlocksPerPixel);
- int screenEndX = (int) Math.round(
- getWidth() * 0.5D + (worldX + frameTileSpan - centerX) / currentBlocksPerPixel);
- int screenEndY = (int) Math.round(
- getHeight() * 0.5D + (worldZ + frameTileSpan - centerZ) / currentBlocksPerPixel);
- int tileWidth = Math.max(1, screenEndX - screenX);
- int tileHeight = Math.max(1, screenEndY - screenY);
- canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
- canvas.fillRect(screenX, screenY, tileWidth, tileHeight);
- BufferedImage image = controller.image(frame, tile);
- if (image != null) {
- canvas.drawImage(image, screenX, screenY, tileWidth, tileHeight, null);
- }
- }
- }
-
- private void renderGrid(Graphics2D canvas) {
- double blocksPerPixel = blocksPerPixel();
- double chunkPixels = 16D / blocksPerPixel;
- if (chunkPixels >= 8D) {
- canvas.setColor(GRID_MINOR);
- drawWorldGrid(canvas, 16D, chunkPixels);
- }
- canvas.setColor(GRID_MAJOR);
- drawWorldGrid(canvas, VisionRenderController.TILE_PIXELS * blocksPerPixel, VisionRenderController.TILE_PIXELS);
- }
-
- private void drawWorldGrid(Graphics2D canvas, double blockSpacing, double pixelSpacing) {
- double leftWorld = screenToWorldX(0D);
- double topWorld = screenToWorldZ(0D);
- double firstX = Math.floor(leftWorld / blockSpacing) * blockSpacing;
- double firstZ = Math.floor(topWorld / blockSpacing) * blockSpacing;
- double firstScreenX = worldToScreenX(firstX);
- double firstScreenZ = worldToScreenZ(firstZ);
- for (double screen = firstScreenX; screen <= getWidth(); screen += pixelSpacing) {
- int screenX = (int) Math.round(screen);
- canvas.drawLine(screenX, 0, screenX, getHeight() - STATUS_HEIGHT);
- }
- for (double screen = firstScreenZ; screen <= getHeight(); screen += pixelSpacing) {
- int screenY = (int) Math.round(screen);
- canvas.drawLine(0, screenY, getWidth(), screenY);
- }
- }
-
- private void renderMarkers(Graphics2D canvas) {
- if (entitiesVisible) {
- for (GuiMarker marker : entities) {
- int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
- int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
- canvas.setColor(MOB_COLOR);
- canvas.fillRect(screenX - 2, screenY - 2, 5, 5);
- }
- }
-
- for (GuiMarker marker : players) {
- int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
- int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
- canvas.setColor(new Color(81, 201, 128, 48));
- canvas.fillOval(screenX - 12, screenY - 12, 24, 24);
- canvas.setColor(PLAYER_COLOR);
- canvas.fillOval(screenX - 5, screenY - 5, 10, 10);
- canvas.setFont(BODY_FONT);
- canvas.setColor(TEXT_PRIMARY);
- int labelWidth = canvas.getFontMetrics().stringWidth(marker.label());
- canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 14);
- }
- if (entitiesVisible && detailedHover) {
- renderNearestEntity(canvas);
- }
- }
-
- private void renderNearestEntity(Graphics2D canvas) {
- Point point = cursor;
- if (point == null || entities.isEmpty()) {
- return;
- }
- double worldX = screenToWorldX(point.x);
- double worldZ = screenToWorldZ(point.y);
- GuiMarker nearest = null;
- double nearestDistance = Double.MAX_VALUE;
- for (GuiMarker marker : entities) {
- double deltaX = marker.worldX() - worldX;
- double deltaZ = marker.worldZ() - worldZ;
- double distance = deltaX * deltaX + deltaZ * deltaZ;
- if (distance < nearestDistance) {
- nearestDistance = distance;
- nearest = marker;
- }
- }
- if (nearest == null) {
- return;
- }
- ArrayList lines = new ArrayList<>();
- lines.add(nearest.label());
- lines.add("Position: " + ((int) nearest.worldX()) + ", " + ((int) nearest.worldY()) + ", " + ((int) nearest.worldZ()));
- if (nearest.maxHealth() > 0D) {
- lines.add("Health: " + Form.f(nearest.health(), 1) + " / " + Form.f(nearest.maxHealth(), 1));
- }
- drawCard(canvas, getWidth() - CARD_PADDING, CARD_PADDING, 1D, 0D, lines);
- }
-
- private void renderLegend(Graphics2D canvas) {
- if (currentType == RenderType.HEIGHT) {
- renderHeightLegend(canvas);
- }
- }
-
- private void renderHeightLegend(Graphics2D canvas) {
- int width = 244;
- int height = 54;
- int x = getWidth() - width - CARD_PADDING;
- int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING;
- drawCardBackground(canvas, x, y, width, height);
- int gradientX = x + CARD_PADDING;
- int gradientY = y + CARD_PADDING;
- int gradientWidth = width - CARD_PADDING * 2;
- for (int pixel = 0; pixel < gradientWidth; pixel++) {
- double sampleHeight = heightMaximum * pixel / Math.max(1D, gradientWidth - 1D);
- canvas.setColor(new Color(IrisRenderer.heightColor(sampleHeight, heightMaximum, fluidHeight)));
- canvas.drawLine(gradientX + pixel, gradientY, gradientX + pixel, gradientY + 14);
- }
- canvas.setColor(CARD_BORDER);
- canvas.drawRect(gradientX, gradientY, gradientWidth, 14);
- canvas.setFont(STATUS_FONT);
- canvas.setColor(TEXT_SECONDARY);
- canvas.drawString("0", gradientX, gradientY + 32);
- String fluid = Integer.toString((int) Math.round(fluidHeight));
- int fluidX = gradientX + (int) Math.round(gradientWidth * fluidHeight / Math.max(1D, heightMaximum));
- canvas.drawString(fluid, Math.max(gradientX, fluidX - canvas.getFontMetrics().stringWidth(fluid) / 2), gradientY + 32);
- String maximum = Integer.toString((int) Math.round(heightMaximum));
- canvas.drawString(maximum, gradientX + gradientWidth - canvas.getFontMetrics().stringWidth(maximum), gradientY + 32);
- }
-
- private void renderStatus(Graphics2D canvas) {
- VisionRenderController.Frame frame = renderFrame;
- VisionRenderController.Progress progress = controller.progress(frame);
- int statusY = getHeight() - STATUS_HEIGHT;
- int progressY = statusY - PROGRESS_HEIGHT;
- canvas.setColor(CONTROL_BACKGROUND);
- canvas.fillRect(0, progressY, getWidth(), PROGRESS_HEIGHT);
- canvas.setColor(ACCENT);
- canvas.fillRect(0, progressY, (int) Math.round(getWidth() * progress.completion()), PROGRESS_HEIGHT);
- canvas.setColor(STATUS_BACKGROUND);
- canvas.fillRect(0, statusY, getWidth(), STATUS_HEIGHT);
- canvas.setColor(CARD_BORDER);
- canvas.drawLine(0, statusY, getWidth(), statusY);
- canvas.setFont(STATUS_FONT);
- canvas.setColor(TEXT_SECONDARY);
-
- int viewWidthBlocks = (int) (blocksPerPixel() * getWidth());
- int viewHeightBlocks = (int) (blocksPerPixel() * Math.max(0, getHeight() - STATUS_HEIGHT));
- String left = modeName(currentType) + " | " + Form.f(blocksPerPixel(), 1) + " bpp | "
- + Form.f(viewWidthBlocks) + " x " + Form.f(viewHeightBlocks) + " blocks";
- canvas.drawString(left, 9, statusY + 19);
-
- String right = "X: " + Form.f((int) centerX) + " Z: " + Form.f((int) centerZ) + " | " + paintCadenceFps + " FPS";
- int rightWidth = canvas.getFontMetrics().stringWidth(right);
- canvas.drawString(right, getWidth() - rightWidth - 9, statusY + 19);
-
- String middle = progress.ready() + "/" + progress.total() + " exact "
- + progress.active() + "+" + progress.queued();
- int middleWidth = canvas.getFontMetrics().stringWidth(middle);
- if (middleWidth + canvas.getFontMetrics().stringWidth(left) + rightWidth + 54 < getWidth()) {
- canvas.drawString(middle, (getWidth() - middleWidth) / 2, statusY + 19);
- }
- }
-
- private void renderHover(Graphics2D canvas) {
- HoverInfo info = hoverInfo;
- if (info == null) {
- return;
- }
- ArrayList lines = new ArrayList<>();
- lines.add(info.biomeName());
- lines.add(info.regionName());
- lines.add("Block " + ((int) info.worldX()) + ", " + ((int) info.worldZ()));
- if (detailedHover) {
- lines.add("Chunk " + ((int) info.worldX() >> 4) + ", " + ((int) info.worldZ() >> 4));
- lines.add("Region " + ((int) info.worldX() >> 9) + ", " + ((int) info.worldZ() >> 9));
- lines.add("Key: " + info.biomeKey());
- lines.add("File: " + info.biomeFile());
- }
- int screenX = (int) Math.round(worldToScreenX(info.worldX())) + 16;
- int screenY = (int) Math.round(worldToScreenZ(info.worldZ()));
- drawCard(canvas, screenX, screenY, 0D, 0D, lines);
- }
-
- private void renderHelp(Graphics2D canvas) {
- List keys = List.of("/", "R", "F", "+/-", "\\", "M", "G", "Shift", "Ctrl+Click", "Alt+Click");
- List descriptions = List.of(
- "Toggle help",
- "Refresh tiles",
- "Follow player",
- "Zoom in/out",
- "Reset zoom",
- "Cycle render mode",
- "Toggle grid",
- "Detailed biome info",
- "Teleport to cursor",
- "Open biome in editor"
- );
- int keyWidth = 0;
- canvas.setFont(KEY_FONT);
- for (String key : keys) {
- keyWidth = Math.max(keyWidth, canvas.getFontMetrics().stringWidth(key));
- }
- int lineHeight = 20;
- int width = keyWidth + 206;
- int height = keys.size() * lineHeight + CARD_PADDING * 2;
- drawCardBackground(canvas, CARD_PADDING, CARD_PADDING, width, height);
- for (int index = 0; index < keys.size(); index++) {
- int y = CARD_PADDING * 2 + 14 + index * lineHeight;
- canvas.setFont(KEY_FONT);
- canvas.setColor(ACCENT);
- canvas.drawString(keys.get(index), CARD_PADDING * 2, y);
- canvas.setFont(BODY_FONT);
- canvas.setColor(TEXT_SECONDARY);
- canvas.drawString(descriptions.get(index), CARD_PADDING * 2 + keyWidth + 14, y);
- }
- }
-
- private void renderDebug(Graphics2D canvas) {
- VisionRenderController.Progress progress = controller.progress(renderFrame);
- ArrayList lines = new ArrayList<>();
- lines.add("Atlas pages: " + progress.ready() + " / " + progress.total() + " exact");
- lines.add("Workers: " + progress.active() + " active / " + progress.queued() + " queued");
- lines.add("Center: " + Form.f((int) centerX) + ", " + Form.f((int) centerZ));
- drawCard(canvas, CARD_PADDING, getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - CARD_PADDING, 0D, 1D, lines);
- }
-
- private void renderNotifications(Graphics2D canvas) {
- if (notifications.isEmpty()) {
- return;
- }
- String text = String.join(" · ", notifications.keySet());
- canvas.setFont(BODY_BOLD_FONT);
- int textWidth = canvas.getFontMetrics().stringWidth(text);
- int width = textWidth + CARD_PADDING * 2;
- int height = 36;
- int x = (getWidth() - width) / 2;
- int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - 14;
- drawCardBackground(canvas, x, y, width, height);
- canvas.setColor(TEXT_PRIMARY);
- canvas.drawString(text, x + CARD_PADDING, y + 23);
- }
-
- private void drawCard(Graphics2D canvas, int anchorX, int anchorY, double pushX, double pushY, List lines) {
- canvas.setFont(BODY_FONT);
- int lineHeight = canvas.getFontMetrics().getHeight();
- int width = 0;
- for (String line : lines) {
- width = Math.max(width, canvas.getFontMetrics().stringWidth(line));
- }
- width += CARD_PADDING * 2;
- int height = lines.size() * lineHeight + CARD_PADDING * 2 - 2;
- int x = (int) Math.round(anchorX - width * pushX);
- int y = (int) Math.round(anchorY - height * pushY);
- x = Math.max(CARD_PADDING, Math.min(getWidth() - width - CARD_PADDING, x));
- y = Math.max(CARD_PADDING, Math.min(getHeight() - STATUS_HEIGHT - height - CARD_PADDING, y));
- drawCardBackground(canvas, x, y, width, height);
- for (int index = 0; index < lines.size(); index++) {
- canvas.setFont(index == 0 ? BODY_BOLD_FONT : BODY_FONT);
- canvas.setColor(index == 0 ? TEXT_PRIMARY : TEXT_SECONDARY);
- canvas.drawString(lines.get(index), x + CARD_PADDING, y + CARD_PADDING + lineHeight - 3 + index * lineHeight);
- }
- }
-
- private void drawCardBackground(Graphics2D canvas, int x, int y, int width, int height) {
- RoundRectangle2D card = new RoundRectangle2D.Double(x, y, width, height, CARD_RADIUS, CARD_RADIUS);
- canvas.setColor(CARD_BACKGROUND);
- canvas.fill(card);
- canvas.setColor(CARD_BORDER);
- canvas.draw(card);
- }
-
- private void requestRender() {
- if (closed || getWidth() < 1 || getHeight() < 1 || !ensureEngine()) {
- return;
- }
- hoverInfo = null;
- VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec(
- renderer,
- currentType,
- contentRevision,
- centerX,
- centerZ,
- blocksPerPixel,
- getWidth(),
- getHeight()
- );
- renderFrame = controller.request(spec);
- repaint();
- }
-
- private boolean ensureEngine() {
- Engine activeEngine = engine;
- if (activeEngine != null && !activeEngine.isClosed() && activeEngine.getComplex() != null) {
- return true;
- }
- try {
- Engine reacquired = GuiHost.findActiveEngine();
- if (reacquired == null || reacquired.isClosed() || reacquired.getComplex() == null) {
- return false;
- }
- engine = reacquired;
- renderer = new IrisRenderer(reacquired);
- overlay = new VisionOverlay(reacquired, openerId);
- contentRevision++;
- captureHeightRange();
- return true;
- } catch (Throwable error) {
- Iris.debug("Vision engine reacquisition failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
- return false;
- }
- }
-
- private void refreshContent() {
- if (closed) {
- return;
- }
- if (!ensureEngine()) {
- notifyUser("No active Iris engine");
- return;
- }
- renderer = new IrisRenderer(engine);
- overlay = new VisionOverlay(engine, openerId);
- contentRevision++;
- captureHeightRange();
- requestRender();
- notifyUser("Refreshing");
- }
-
- private void captureHeightRange() {
- heightMaximum = Math.max(1D, engine.getHeight());
- IrisDimension dimension = engine.getDimension();
- fluidHeight = dimension == null
- ? 0D
- : Math.max(0D, Math.min(heightMaximum, dimension.getFluidHeight()));
- }
-
- private void refreshMarkers() {
- if (closed || overlay == null) {
- return;
- }
- try {
- List nextPlayers = overlay.lastKnownPlayers();
- players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers);
- if (entitiesVisible) {
- overlay.requestEntities(next -> EventQueue.invokeLater(() -> {
- if (!closed && entitiesVisible) {
- entities = next == null ? List.of() : List.copyOf(next);
- repaint();
- }
- }));
- } else if (!entities.isEmpty()) {
- entities = List.of();
- }
- if (follow && !players.isEmpty()) {
- GuiMarker player = players.get(0);
- if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) {
- centerX = player.worldX();
- centerZ = player.worldZ();
- requestRender();
- }
- }
- repaint();
- } catch (Throwable error) {
- Iris.debug("Vision marker refresh failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
- }
- }
-
- private void requestHover() {
- Point point = cursor;
- VisionRenderController.Frame frame = renderFrame;
- Engine probeEngine = engine;
- if (closed || point == null || frame == null || probeEngine == null || probeEngine.getComplex() == null) {
- return;
- }
- double worldX = screenToWorldX(point.x);
- double worldZ = screenToWorldZ(point.y);
- controller.submitProbe(
- frame,
- () -> sampleHover(probeEngine, worldX, worldZ),
- info -> {
- hoverInfo = info;
- 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())
- );
- }
-
- private void setRenderType(RenderType type) {
- if (type == null || type == currentType) {
- return;
- }
- currentType = type;
- syncControls();
- requestRender();
- notifyUser(modeName(type));
- }
-
- private void toggleGrid() {
- grid = !grid;
- syncControls();
- repaint();
- notifyUser(grid ? "Grid enabled" : "Grid disabled");
- }
-
- private void toggleEntities() {
- entitiesVisible = !entitiesVisible;
- if (entitiesVisible) {
- refreshMarkers();
- } else {
- entities = List.of();
- repaint();
- }
- syncControls();
- notifyUser(entitiesVisible ? "Entities enabled" : "Entities disabled");
- }
-
- private void toggleFollow() {
- follow = !follow;
- if (follow && players.isEmpty()) {
- follow = false;
- notifyUser("No player in world");
- } else if (follow) {
- GuiMarker player = players.get(0);
- centerX = player.worldX();
- centerZ = player.worldZ();
- requestRender();
- notifyUser("Following " + player.label());
- } else {
- notifyUser("Follow disabled");
- }
- syncControls();
- }
-
- private void syncControls() {
- controlsUpdating = true;
- try {
- if (modeSelector != null) {
- modeSelector.setSelectedItem(currentType);
- }
- if (gridToggle != null) {
- gridToggle.setSelected(grid);
- }
- if (entitiesToggle != null) {
- entitiesToggle.setSelected(entitiesVisible);
- }
- if (followToggle != null) {
- followToggle.setSelected(follow);
- }
- } finally {
- controlsUpdating = false;
- }
- }
-
- private void notifyUser(String message) {
- notifications.put(message, System.currentTimeMillis() + 2500L);
- repaint();
- }
-
- private void expireNotifications() {
- long now = System.currentTimeMillis();
- boolean changed = false;
- Iterator> iterator = notifications.entrySet().iterator();
- while (iterator.hasNext()) {
- if (iterator.next().getValue() <= now) {
- iterator.remove();
- changed = true;
- }
- }
- if (changed) {
- repaint();
- }
- }
-
- private void changeZoom(double factor, Point anchor) {
- if (!Double.isFinite(factor) || factor <= 0D) {
- return;
- }
- Point effectiveAnchor = anchor == null ? new Point(getWidth() / 2, getHeight() / 2) : anchor;
- VisionViewport next = new VisionViewport(centerX, centerZ, blocksPerPixel).zoomAt(
- effectiveAnchor.x,
- effectiveAnchor.y,
- getWidth(),
- getHeight(),
- factor,
- VisionRenderController.MINIMUM_BLOCKS_PER_PIXEL,
- VisionRenderController.MAXIMUM_BLOCKS_PER_PIXEL
- );
- if (next.blocksPerPixel() == blocksPerPixel) {
- return;
- }
- centerX = next.centerX();
- centerZ = next.centerZ();
- blocksPerPixel = next.blocksPerPixel();
- follow = false;
- hoverInfo = null;
- syncControls();
- zoomRenderTimer.restart();
- repaint();
- }
-
- private double blocksPerPixel() {
- return blocksPerPixel;
- }
-
- private double screenToWorldX(double screenX) {
- return centerX + (screenX - getWidth() * 0.5D) * blocksPerPixel();
- }
-
- private double screenToWorldZ(double screenY) {
- return centerZ + (screenY - getHeight() * 0.5D) * blocksPerPixel();
- }
-
- private double worldToScreenX(double worldX) {
- return getWidth() * 0.5D + (worldX - centerX) / blocksPerPixel();
- }
-
- private double worldToScreenZ(double worldZ) {
- return getHeight() * 0.5D + (worldZ - centerZ) / blocksPerPixel();
- }
-
- private void openAtCursor() {
- VisionOverlay activeOverlay = overlay;
- Point point = cursor;
- VisionRenderController.Frame frame = renderFrame;
- if (activeOverlay == null || point == null || frame == null) {
- return;
- }
- double worldX = screenToWorldX(point.x);
- double worldZ = screenToWorldZ(point.y);
- RenderType type = currentType;
- controller.submitProbe(
- frame,
- () -> activeOverlay.openInEditor(worldX, worldZ, type),
- opened -> opened.ifPresent(fileName -> notifyUser("Opened " + fileName))
- );
- }
-
- private void teleportToCursor() {
- Point point = cursor;
- if (overlay == null || point == null) {
- notifyUser("No player in world");
- return;
- }
- int worldX = floorWorldCoordinate(screenToWorldX(point.x));
- int worldZ = floorWorldCoordinate(screenToWorldZ(point.y));
- overlay.teleport(worldX, worldZ);
- notifyUser("Teleporting to " + worldX + ", " + worldZ);
- }
-
- private void close() {
- if (closed) {
- return;
- }
- closed = true;
- resizeTimer.stop();
- dragRenderTimer.stop();
- zoomRenderTimer.stop();
- hoverTimer.stop();
- markerTimer.stop();
- notificationTimer.stop();
- GuiHost.unregisterHotloadHook(hotloadHook);
- hostFrame.removeKeyListener(this);
- controller.close();
- }
-
- static int floorWorldCoordinate(double coordinate) {
- return (int) StrictMath.floor(coordinate);
- }
-
- private static String modeName(RenderType type) {
- return switch (type) {
- case BIOME -> "Biome";
- case BIOME_LAND -> "Biome land";
- case BIOME_SEA -> "Biome sea";
- case REGION -> "Region";
- case CAVE_LAND -> "Cave land";
- case HEIGHT -> "Height";
- case OBJECT_LOAD -> "Object load";
- case DECORATOR_LOAD -> "Decorator load";
- case CONTINENT -> "Continent";
- case LAYER_LOAD -> "Layer load";
- };
- }
-
- @Override
- public void mouseMoved(MouseEvent event) {
- cursor = event.getPoint();
- hoverTimer.restart();
- }
-
- @Override
- public void mouseDragged(MouseEvent event) {
- cursor = event.getPoint();
- double deltaX = dragX - event.getX();
- double deltaY = dragY - event.getY();
- centerX += deltaX * blocksPerPixel();
- centerZ += deltaY * blocksPerPixel();
- dragX = event.getX();
- dragY = event.getY();
- follow = false;
- hoverInfo = null;
- syncControls();
- dragRenderTimer.restart();
- repaint();
- }
-
- @Override
- public void mouseWheelMoved(MouseWheelEvent event) {
- if (!event.isControlDown()) {
- cursor = event.getPoint();
- double factor = Math.pow(2D, event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT);
- changeZoom(factor, cursor);
- event.consume();
- }
- }
-
- @Override
- public void mouseClicked(MouseEvent event) {
- cursor = event.getPoint();
- if (event.isControlDown()) {
- teleportToCursor();
- } else if (event.isAltDown()) {
- openAtCursor();
- }
- }
-
- @Override
- public void mousePressed(MouseEvent event) {
- requestFocusInWindow();
- cursor = event.getPoint();
- dragX = event.getX();
- dragY = event.getY();
- }
-
- @Override
- public void mouseReleased(MouseEvent event) {
- if (dragRenderTimer.isRunning()) {
- dragRenderTimer.stop();
- requestRender();
- }
- }
-
- @Override
- public void mouseEntered(MouseEvent event) {
- cursor = event.getPoint();
- }
-
- @Override
- public void mouseExited(MouseEvent event) {
- cursor = null;
- hoverInfo = null;
- hoverTimer.stop();
- repaint();
- }
-
- @Override
- public void keyPressed(KeyEvent event) {
- if (event.getKeyCode() == KeyEvent.VK_SHIFT) {
- detailedHover = true;
- repaint();
- } else if (event.getKeyCode() == KeyEvent.VK_SEMICOLON) {
- debug = true;
- repaint();
- }
- }
-
- @Override
- public void keyReleased(KeyEvent event) {
- int keyCode = event.getKeyCode();
- if (keyCode == KeyEvent.VK_SHIFT) {
- detailedHover = false;
- repaint();
- return;
- }
- if (keyCode == KeyEvent.VK_SEMICOLON) {
- debug = false;
- repaint();
- return;
- }
- if (keyCode == KeyEvent.VK_SLASH) {
- help = !help;
- repaint();
- return;
- }
- if (keyCode == KeyEvent.VK_R) {
- refreshContent();
- return;
- }
- if (keyCode == KeyEvent.VK_F) {
- toggleFollow();
- return;
- }
- if (keyCode == KeyEvent.VK_G) {
- toggleGrid();
- return;
- }
- if (keyCode == KeyEvent.VK_EQUALS || keyCode == KeyEvent.VK_ADD) {
- changeZoom(1D / KEYBOARD_ZOOM_FACTOR, cursor);
- return;
- }
- if (keyCode == KeyEvent.VK_MINUS || keyCode == KeyEvent.VK_SUBTRACT) {
- changeZoom(KEYBOARD_ZOOM_FACTOR, cursor);
- return;
- }
- if (keyCode == KeyEvent.VK_BACK_SLASH) {
- changeZoom(DEFAULT_BLOCKS_PER_PIXEL / blocksPerPixel, null);
- notifyUser("Zoom reset");
- return;
- }
- if (keyCode == KeyEvent.VK_M) {
- RenderType[] types = RenderType.values();
- setRenderType(types[(currentType.ordinal() + 1) % types.length]);
- return;
- }
-
- int modeIndex = modeIndex(keyCode);
- RenderType[] types = RenderType.values();
- if (modeIndex >= 0 && modeIndex < types.length) {
- setRenderType(types[modeIndex]);
- }
- }
-
- private static int modeIndex(int keyCode) {
- if (keyCode >= KeyEvent.VK_1 && keyCode <= KeyEvent.VK_9) {
- return keyCode - KeyEvent.VK_1;
- }
- if (keyCode == KeyEvent.VK_0) {
- return 9;
- }
- if (keyCode == KeyEvent.VK_BACK_QUOTE) {
- return 10;
- }
- return -1;
- }
-
- @Override
- public void keyTyped(KeyEvent event) {
- }
-
- private record HoverInfo(
- double worldX,
- double worldZ,
- String biomeName,
- String regionName,
- String biomeKey,
- String biomeFile
- ) {
- }
-
- private static final class ModeCellRenderer extends DefaultListCellRenderer {
- private static final long serialVersionUID = -3387015482484702284L;
-
- @Override
- public Component getListCellRendererComponent(
- JList> list,
- Object value,
- int index,
- boolean selected,
- boolean focus
- ) {
- Component component = super.getListCellRendererComponent(list, value, index, selected, focus);
- if (component instanceof JLabel label && value instanceof RenderType type) {
- label.setText(modeName(type));
- label.setFont(TOOLBAR_FONT);
- label.setForeground(selected ? TEXT_PRIMARY : TEXT_SECONDARY);
- label.setBackground(selected ? CONTROL_SELECTED : CONTROL_BACKGROUND);
- label.setBorder(BorderFactory.createEmptyBorder(3, 7, 3, 7));
- }
- return component;
- }
- }
-}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java b/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java
index 2dd73a60b..0699331c2 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java
@@ -26,6 +26,8 @@ import java.awt.EventQueue;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -35,9 +37,7 @@ import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Future;
-import java.util.concurrent.FutureTask;
+import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
@@ -47,50 +47,49 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
+/**
+ * Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn.
+ * Tiles keep rendering while the view moves, as long as they are still on screen.
+ */
final class VisionRenderController implements AutoCloseable {
static final int TILE_PIXELS = 64;
- static final double MINIMUM_BLOCKS_PER_PIXEL = 1D;
+ static final double MINIMUM_VIEW_BLOCKS_PER_PIXEL = 1D / 8D;
static final double MAXIMUM_BLOCKS_PER_PIXEL = 4_096D;
- private static final int MAXIMUM_WORKERS = 3;
+ private static final double MINIMUM_RENDER_BLOCKS_PER_PIXEL = 1D;
private static final int MAXIMUM_VISIBLE_TILES = 32_768;
- private static final int RENDER_QUEUE_CAPACITY = 12;
private static final int PROBE_QUEUE_CAPACITY = 1;
- private static final long CACHE_BYTES = 64L * 1024L * 1024L;
+ private static final int COARSER_FALLBACK_LEVELS = 6;
+ private static final long CACHE_BYTES = 128L * 1024L * 1024L;
private final Runnable listener;
- private final int renderWorkerCount;
+ private final int workerCount;
private final ThreadPoolExecutor renderExecutor;
private final ThreadPoolExecutor probeExecutor;
private final WeightedTileCache cache;
- private final AtomicLong viewSequence;
- private final AtomicLong probeSequence;
- private final AtomicBoolean closed;
- private final AtomicBoolean publicationQueued;
- private final AtomicBoolean publicationDirty;
- private final Set> activeRenderTasks;
+ private final Set tilesInFlight = new HashSet<>();
+ private final AtomicInteger activeWorkers = new AtomicInteger();
+ private final AtomicLong viewSequence = new AtomicLong();
+ private final AtomicLong probeSequence = new AtomicLong();
+ private final AtomicBoolean closed = new AtomicBoolean();
+ private final AtomicBoolean publicationQueued = new AtomicBoolean();
+ private final AtomicBoolean publicationDirty = new AtomicBoolean();
private volatile Frame currentFrame;
- private volatile WorkState currentWork;
- private volatile CancellationToken currentToken;
VisionRenderController(Runnable listener) {
- this(listener, RuntimeOptions.production());
- }
-
- VisionRenderController(Runnable listener, RuntimeOptions options) {
this.listener = Objects.requireNonNull(listener, "listener");
- Objects.requireNonNull(options, "options");
+ int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
+ this.workerCount = Math.max(1, Math.min(6, processors / 2));
AtomicInteger threadSequence = new AtomicInteger();
- this.renderWorkerCount = options.workers();
this.renderExecutor = new ThreadPoolExecutor(
- options.workers(),
- options.workers(),
- 0L,
- TimeUnit.MILLISECONDS,
- new ArrayBlockingQueue<>(options.renderQueueCapacity()),
- daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence),
- new ThreadPoolExecutor.AbortPolicy()
+ workerCount,
+ workerCount,
+ 30L,
+ TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>(),
+ daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence)
);
+ this.renderExecutor.allowCoreThreadTimeOut(true);
this.probeExecutor = new ThreadPoolExecutor(
1,
1,
@@ -101,60 +100,46 @@ final class VisionRenderController implements AutoCloseable {
new ThreadPoolExecutor.DiscardOldestPolicy()
);
this.cache = new WeightedTileCache(CACHE_BYTES);
- this.viewSequence = new AtomicLong();
- this.probeSequence = new AtomicLong();
- this.closed = new AtomicBoolean();
- this.publicationQueued = new AtomicBoolean();
- this.publicationDirty = new AtomicBoolean();
- this.activeRenderTasks = ConcurrentHashMap.newKeySet();
GuiHost.interruptOnPluginShutdown(renderExecutor);
GuiHost.interruptOnPluginShutdown(probeExecutor);
}
- synchronized Frame request(RenderSpec spec) {
+ // Snapping to powers of two keeps every zoom within a level on the same tiles; nearest keeps tiles within 1.41x of screen size.
+ static double renderBlocksPerPixel(double viewBlocksPerPixel) {
+ double level = Math.pow(2D, Math.round(Math.log(viewBlocksPerPixel) / Math.log(2D)));
+ return Math.max(MINIMUM_RENDER_BLOCKS_PER_PIXEL, Math.min(MAXIMUM_BLOCKS_PER_PIXEL, level));
+ }
+
+ Frame request(RenderSpec spec) {
Objects.requireNonNull(spec, "spec");
if (closed.get()) {
throw new IllegalStateException("Vision render controller is closed");
}
-
- CancellationToken previousToken = currentToken;
- if (previousToken != null) {
- previousToken.cancel();
+ double renderScale = renderBlocksPerPixel(spec.blocksPerPixel());
+ List tiles = visibleTiles(spec, renderScale);
+ Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
+ for (VisibleTile tile : tiles) {
+ tile.setImage(cache.get(tile.key()));
}
- renderExecutor.getQueue().clear();
- cancelActiveRenderTasks();
+ currentFrame = frame;
probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
-
- List tiles = visibleTiles(spec);
- Frame frame = new Frame(viewSequence.incrementAndGet(), spec, tiles);
- for (VisibleTile tile : tiles) {
- tile.setImage(cache.get(frame.key(tile)));
- }
- CancellationToken token = new CancellationToken();
- WorkState work = new WorkState(frame, token);
- currentFrame = frame;
- currentToken = token;
- currentWork = work;
- schedule(work);
- publish(frame, token);
+ startWorkers();
+ publish();
return frame;
}
- Frame currentFrame() {
- return currentFrame;
- }
-
- BufferedImage image(Frame frame, VisibleTile tile) {
- if (frame == null || tile == null) {
- return null;
- }
- return tile.image();
+ /**
+ * A tile from another zoom level covering the given one, to draw while the real tile renders.
+ */
+ Fallback fallback(Frame frame, VisibleTile tile) {
+ Fallback coarser = coarserFallback(frame, tile);
+ return coarser != null ? coarser : finerFallback(frame, tile);
}
Progress progress(Frame frame) {
if (frame == null) {
- return new Progress(0, 0, 0, 0);
+ return new Progress(0, 0, 0);
}
int ready = 0;
for (VisibleTile tile : frame.tiles()) {
@@ -162,7 +147,7 @@ final class VisionRenderController implements AutoCloseable {
ready++;
}
}
- return new Progress(frame.tiles().size(), ready, renderExecutor.getActiveCount(), renderExecutor.getQueue().size());
+ return new Progress(frame.tiles().size(), ready, activeWorkers.get());
}
void submitProbe(Frame frame, Callable probe, Consumer consumer) {
@@ -174,35 +159,27 @@ final class VisionRenderController implements AutoCloseable {
long probeRevision = probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
try {
- probeExecutor.execute(() -> runProbe(frame, probeRevision, probe, consumer));
+ probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer));
} catch (RejectedExecutionException ignored) {
probeExecutor.getQueue().clear();
}
}
@Override
- public synchronized void close() {
+ public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
- CancellationToken token = currentToken;
- if (token != null) {
- token.cancel();
- }
- currentWork = null;
currentFrame = null;
- renderExecutor.getQueue().clear();
- cancelActiveRenderTasks();
- probeExecutor.getQueue().clear();
renderExecutor.shutdownNow();
probeExecutor.shutdownNow();
cache.clear();
}
- static List visibleTiles(RenderSpec spec) {
+ static List visibleTiles(RenderSpec spec, double renderScale) {
Objects.requireNonNull(spec, "spec");
double blocksPerPixel = spec.blocksPerPixel();
- double tileSpan = TILE_PIXELS * blocksPerPixel;
+ double tileSpan = TILE_PIXELS * renderScale;
double halfWidth = spec.width() * blocksPerPixel * 0.5D;
double halfHeight = spec.height() * blocksPerPixel * 0.5D;
long minimumX = floorTile(spec.centerX() - halfWidth, tileSpan);
@@ -217,19 +194,12 @@ final class VisionRenderController implements AutoCloseable {
ArrayList tiles = new ArrayList<>((int) tileCount);
double centerTileX = spec.centerX() / tileSpan;
double centerTileZ = spec.centerZ() / tileSpan;
- for (long tileZ = minimumZ; ; tileZ++) {
- for (long tileX = minimumX; ; tileX++) {
- int screenX = (int) Math.round(spec.width() * 0.5D + (tileX * tileSpan - spec.centerX()) / blocksPerPixel);
- int screenY = (int) Math.round(spec.height() * 0.5D + (tileZ * tileSpan - spec.centerZ()) / blocksPerPixel);
+ for (long tileZ = minimumZ; tileZ <= maximumZ; tileZ++) {
+ for (long tileX = minimumX; tileX <= maximumX; tileX++) {
double deltaX = tileX + 0.5D - centerTileX;
double deltaZ = tileZ + 0.5D - centerTileZ;
- tiles.add(new VisibleTile(tileX, tileZ, screenX, screenY, deltaX * deltaX + deltaZ * deltaZ));
- if (tileX == maximumX) {
- break;
- }
- }
- if (tileZ == maximumZ) {
- break;
+ TileKey key = new TileKey(spec.contentRevision(), spec.type(), renderScale, tileX, tileZ);
+ tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ));
}
}
tiles.sort(Comparator.comparingDouble(VisibleTile::distanceSquared)
@@ -238,13 +208,6 @@ final class VisionRenderController implements AutoCloseable {
return List.copyOf(tiles);
}
- static long sampleCount(int tileCount) {
- if (tileCount < 1) {
- throw new IllegalArgumentException("Vision tile count must be positive");
- }
- return Math.multiplyExact((long) tileCount, (long) TILE_PIXELS * TILE_PIXELS);
- }
-
private static long floorTile(double coordinate, double tileSpan) {
double tile = Math.floor(coordinate / tileSpan);
if (tile < Long.MIN_VALUE || tile > Long.MAX_VALUE) {
@@ -263,81 +226,142 @@ final class VisionRenderController implements AutoCloseable {
};
}
- private void schedule(WorkState work) {
- synchronized (work) {
- if (!isCurrent(work)) {
+ private void startWorkers() {
+ while (!closed.get()) {
+ int active = activeWorkers.get();
+ if (active >= workerCount || !hasWork()) {
return;
}
- while (work.inFlight() < renderWorkerCount) {
- VisibleTile tile = work.nextMissing();
- if (tile == null) {
- return;
- }
- work.incrementInFlight();
- TrackedRenderTask task = new TrackedRenderTask(() -> render(work, tile), activeRenderTasks);
- activeRenderTasks.add(task);
- try {
- renderExecutor.execute(task);
- } catch (RejectedExecutionException ignored) {
- task.cancel(false);
- work.decrementInFlight();
- return;
- }
+ if (!activeWorkers.compareAndSet(active, active + 1)) {
+ continue;
+ }
+ try {
+ renderExecutor.execute(this::renderUntilIdle);
+ } catch (RejectedExecutionException ignored) {
+ activeWorkers.decrementAndGet();
+ return;
}
}
}
- private void render(WorkState work, VisibleTile tile) {
- Frame frame = work.frame();
- CancellationToken token = work.token();
+ // Each worker keeps taking the most central missing tile of whatever frame is current, until the screen is covered.
+ private void renderUntilIdle() {
try {
- if (!isCurrent(frame, token)) {
- return;
+ TileJob job;
+ while ((job = claimNextTile()) != null) {
+ render(job);
}
- double tileSpan = TILE_PIXELS * frame.spec().blocksPerPixel();
- BufferedImage image = frame.spec().renderer().renderStudio(
- tile.tileX() * tileSpan,
- tile.tileZ() * tileSpan,
+ } finally {
+ activeWorkers.decrementAndGet();
+ }
+ startWorkers();
+ }
+
+ private synchronized boolean hasWork() {
+ Frame frame = currentFrame;
+ return frame != null && frame.nextMissing(tilesInFlight, false) != null;
+ }
+
+ private synchronized TileJob claimNextTile() {
+ Frame frame = currentFrame;
+ if (frame == null || closed.get()) {
+ return null;
+ }
+ VisibleTile tile = frame.nextMissing(tilesInFlight, true);
+ if (tile == null) {
+ return null;
+ }
+ tilesInFlight.add(tile.key());
+ return new TileJob(frame.spec().renderer(), tile.key());
+ }
+
+ private synchronized void release(TileKey key) {
+ tilesInFlight.remove(key);
+ }
+
+ private void render(TileJob job) {
+ TileKey key = job.key();
+ try {
+ double tileSpan = TILE_PIXELS * key.blocksPerPixel();
+ BufferedImage image = job.renderer().renderStudio(
+ key.tileX() * tileSpan,
+ key.tileZ() * tileSpan,
tileSpan,
TILE_PIXELS,
- frame.spec().type(),
- () -> !isCurrent(frame, token)
+ key.type(),
+ () -> !isWanted(key)
);
- if (!isCurrent(frame, token)) {
- return;
+ cache.put(key, image);
+ Frame frame = currentFrame;
+ VisibleTile visible = frame == null ? null : frame.tile(key);
+ if (visible != null) {
+ visible.setImage(image);
+ publish();
}
- cache.put(frame.key(tile), image);
- tile.setImage(image);
- publish(frame, token);
} catch (CancellationException ignored) {
} catch (Throwable error) {
Iris.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
} finally {
- complete(work);
+ release(key);
}
}
- private void complete(WorkState work) {
- synchronized (work) {
- work.decrementInFlight();
- if (!isCurrent(work)) {
- return;
+ private boolean isWanted(TileKey key) {
+ Frame frame = currentFrame;
+ return !closed.get() && frame != null && frame.tile(key) != null;
+ }
+
+ private Fallback coarserFallback(Frame frame, VisibleTile tile) {
+ TileKey key = tile.key();
+ for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
+ long scale = 1L << level;
+ int sourceSize = TILE_PIXELS >> level;
+ double parentScale = key.blocksPerPixel() * scale;
+ if (sourceSize < 1 || parentScale > MAXIMUM_BLOCKS_PER_PIXEL) {
+ return null;
+ }
+ 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));
+ if (parent != null) {
+ int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
+ int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
+ return new Fallback(List.of(new FallbackPiece(parent, sourceX, sourceZ, sourceSize, 0D, 0D, 1D)));
}
}
- schedule(work);
+ return null;
}
- private void runProbe(Frame frame, long probeRevision, Callable probe, Consumer consumer) {
+ private Fallback finerFallback(Frame frame, VisibleTile tile) {
+ TileKey key = tile.key();
+ double childScale = key.blocksPerPixel() / 2D;
+ if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
+ return null;
+ }
+ List pieces = new ArrayList<>(4);
+ for (int childZ = 0; childZ < 2; childZ++) {
+ for (int childX = 0; childX < 2; childX++) {
+ BufferedImage child = cache.get(new TileKey(key.contentRevision(), key.type(), childScale,
+ 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));
+ }
+ }
+ }
+ return pieces.isEmpty() ? null : new Fallback(List.copyOf(pieces));
+ }
+
+ private void runProbe(long probeRevision, Callable probe, Consumer consumer) {
try {
- if (!isProbeCurrent(frame, probeRevision)) {
+ if (!isProbeCurrent(probeRevision)) {
return;
}
T result = probe.call();
- if (!isProbeCurrent(frame, probeRevision)) {
+ if (!isProbeCurrent(probeRevision)) {
return;
}
EventQueue.invokeLater(() -> {
- if (isProbeCurrent(frame, probeRevision)) {
+ if (isProbeCurrent(probeRevision)) {
consumer.accept(result);
}
});
@@ -348,20 +372,12 @@ final class VisionRenderController implements AutoCloseable {
}
}
- private boolean isProbeCurrent(Frame frame, long probeRevision) {
- return !closed.get() && currentFrame == frame && probeSequence.get() == probeRevision;
+ private boolean isProbeCurrent(long probeRevision) {
+ return !closed.get() && probeSequence.get() == probeRevision;
}
- private boolean isCurrent(Frame frame, CancellationToken token) {
- return !closed.get() && !token.cancelled() && currentFrame == frame;
- }
-
- private boolean isCurrent(WorkState work) {
- return currentWork == work && isCurrent(work.frame(), work.token());
- }
-
- private void publish(Frame frame, CancellationToken token) {
- if (!isCurrent(frame, token)) {
+ private void publish() {
+ if (closed.get()) {
return;
}
publicationDirty.set(true);
@@ -386,13 +402,6 @@ final class VisionRenderController implements AutoCloseable {
});
}
- private void cancelActiveRenderTasks() {
- for (Future> task : activeRenderTasks) {
- task.cancel(true);
- }
- activeRenderTasks.clear();
- }
-
record RenderSpec(
IrisRenderer renderer,
RenderType type,
@@ -410,7 +419,7 @@ final class VisionRenderController implements AutoCloseable {
throw new IllegalArgumentException("Vision center must be finite");
}
if (!Double.isFinite(blocksPerPixel)
- || blocksPerPixel < MINIMUM_BLOCKS_PER_PIXEL
+ || blocksPerPixel < MINIMUM_VIEW_BLOCKS_PER_PIXEL
|| blocksPerPixel > MAXIMUM_BLOCKS_PER_PIXEL) {
throw new IllegalArgumentException("Vision scale is outside the supported range");
}
@@ -418,50 +427,85 @@ final class VisionRenderController implements AutoCloseable {
throw new IllegalArgumentException("Vision viewport must be positive");
}
}
-
}
- record Frame(long viewRevision, RenderSpec spec, List tiles) {
- Frame {
- Objects.requireNonNull(spec, "spec");
- tiles = List.copyOf(tiles);
+ static final class Frame {
+ private final long viewRevision;
+ private final RenderSpec spec;
+ private final double renderBlocksPerPixel;
+ private final List tiles;
+ private final Map tilesByKey;
+ private int nextCandidate;
+
+ private Frame(long viewRevision, RenderSpec spec, double renderBlocksPerPixel, List tiles) {
+ this.viewRevision = viewRevision;
+ this.spec = Objects.requireNonNull(spec, "spec");
+ this.renderBlocksPerPixel = renderBlocksPerPixel;
+ this.tiles = tiles;
+ this.tilesByKey = new HashMap<>(tiles.size() * 2);
+ for (VisibleTile tile : tiles) {
+ tilesByKey.put(tile.key(), tile);
+ }
}
- TileKey key(VisibleTile tile) {
- return new TileKey(spec.contentRevision(), spec.type(), spec.blocksPerPixel(), tile.tileX(), tile.tileZ());
+ long viewRevision() {
+ return viewRevision;
+ }
+
+ RenderSpec spec() {
+ return spec;
+ }
+
+ double renderBlocksPerPixel() {
+ return renderBlocksPerPixel;
+ }
+
+ List tiles() {
+ return tiles;
+ }
+
+ private VisibleTile tile(TileKey key) {
+ return tilesByKey.get(key);
+ }
+
+ // Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
+ private VisibleTile nextMissing(Set inFlight, boolean advance) {
+ for (int index = nextCandidate; index < tiles.size(); index++) {
+ VisibleTile tile = tiles.get(index);
+ if (tile.image() == null && !inFlight.contains(tile.key())) {
+ if (advance) {
+ nextCandidate = index + 1;
+ }
+ return tile;
+ }
+ }
+ if (advance) {
+ nextCandidate = tiles.size();
+ }
+ return null;
}
}
static final class VisibleTile {
- private final long tileX;
- private final long tileZ;
- private final int screenX;
- private final int screenY;
+ private final TileKey key;
private final double distanceSquared;
private volatile BufferedImage image;
- private VisibleTile(long tileX, long tileZ, int screenX, int screenY, double distanceSquared) {
- this.tileX = tileX;
- this.tileZ = tileZ;
- this.screenX = screenX;
- this.screenY = screenY;
+ private VisibleTile(TileKey key, double distanceSquared) {
+ this.key = key;
this.distanceSquared = distanceSquared;
}
+ TileKey key() {
+ return key;
+ }
+
long tileX() {
- return tileX;
+ return key.tileX();
}
long tileZ() {
- return tileZ;
- }
-
- int screenX() {
- return screenX;
- }
-
- int screenY() {
- return screenY;
+ return key.tileZ();
}
double distanceSquared() {
@@ -472,7 +516,7 @@ final class VisionRenderController implements AutoCloseable {
return image;
}
- void setImage(BufferedImage image) {
+ private void setImage(BufferedImage image) {
this.image = image;
}
}
@@ -486,7 +530,17 @@ final class VisionRenderController implements AutoCloseable {
}
}
- record Progress(int total, int ready, int active, int queued) {
+ /**
+ * Pieces of cached tiles that together cover a missing tile; offsets and size are fractions of the tile.
+ */
+ record Fallback(List pieces) {
+ }
+
+ record FallbackPiece(BufferedImage image, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
+ double size) {
+ }
+
+ record Progress(int total, int ready, int activeWorkers) {
double completion() {
if (total == 0) {
return 1D;
@@ -495,86 +549,7 @@ final class VisionRenderController implements AutoCloseable {
}
}
- record RuntimeOptions(int workers, int renderQueueCapacity) {
- RuntimeOptions {
- if (workers < 1 || renderQueueCapacity < 1) {
- throw new IllegalArgumentException("Vision render runtime options are invalid");
- }
- }
-
- static RuntimeOptions production() {
- int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
- int workers = Math.max(1, Math.min(MAXIMUM_WORKERS, processors));
- return new RuntimeOptions(workers, RENDER_QUEUE_CAPACITY);
- }
- }
-
- private static final class CancellationToken {
- private final AtomicBoolean cancelled = new AtomicBoolean();
-
- void cancel() {
- cancelled.set(true);
- }
-
- boolean cancelled() {
- return cancelled.get();
- }
- }
-
- private static final class TrackedRenderTask extends FutureTask {
- private final Set> tasks;
-
- private TrackedRenderTask(Runnable task, Set> tasks) {
- super(task, null);
- this.tasks = tasks;
- }
-
- @Override
- protected void done() {
- tasks.remove(this);
- }
- }
-
- private static final class WorkState {
- private final Frame frame;
- private final CancellationToken token;
- private int index;
- private int inFlight;
-
- private WorkState(Frame frame, CancellationToken token) {
- this.frame = frame;
- this.token = token;
- }
-
- Frame frame() {
- return frame;
- }
-
- CancellationToken token() {
- return token;
- }
-
- VisibleTile nextMissing() {
- while (index < frame.tiles().size()) {
- VisibleTile tile = frame.tiles().get(index++);
- if (tile.image() == null) {
- return tile;
- }
- }
- return null;
- }
-
- int inFlight() {
- return inFlight;
- }
-
- void incrementInFlight() {
- inFlight++;
- }
-
- void decrementInFlight() {
- inFlight--;
- }
+ private record TileJob(IrisRenderer renderer, TileKey key) {
}
private static final class WeightedTileCache {
diff --git a/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java
index ca778385f..bc9974512 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java
@@ -28,7 +28,6 @@ import com.volmit.iris.util.stream.ProceduralStream;
import java.awt.Color;
import java.awt.image.BufferedImage;
-import java.awt.image.DataBufferInt;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.List;
@@ -98,17 +97,36 @@ public final class IrisRenderer {
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
+ int[] pixels = renderPixels(sx, sz, size, resolution, currentType, cancelled, studio);
+ return managedImage(pixels, resolution);
+ }
+
+ // Filled through the raster rather than its backing array, so Java2D may keep the image on the GPU between paints.
+ private static BufferedImage managedImage(int[] pixels, int resolution) {
BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB);
- int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
+ image.getRaster().setDataElements(0, 0, resolution, resolution, pixels);
+ return image;
+ }
+
+ 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 image;
+ return pixels;
}
PixelShader shader = shader(currentType, studio);
if (studio && adaptiveStudioType(currentType)) {
renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
- return image;
+ return pixels;
}
int groupSize = sampleGroup(step, resolution);
@@ -130,7 +148,7 @@ public final class IrisRenderer {
}
}
- return image;
+ return pixels;
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {