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) {