From 29942fa1c2ac983d489a58911dabea7942d30b4f Mon Sep 17 00:00:00 2001 From: piexel Date: Wed, 21 Oct 2026 18:38:26 +0200 Subject: [PATCH] Explore region biomes by height Preview, height chart; hybrid coastlines. --- .../com/volmit/iris/core/gui/MapCanvas.java | 49 ++- .../volmit/iris/core/gui/MapHoverCard.java | 21 ++ .../volmit/iris/core/gui/MapHoverProbe.java | 19 +- .../com/volmit/iris/core/gui/MapView.java | 5 +- .../com/volmit/iris/core/gui/MapWindow.java | 42 ++- .../volmit/iris/core/gui/RegionExplorer.java | 218 +++++++++++ .../iris/core/gui/RegionHeightChart.java | 343 ++++++++++++++++++ .../iris/core/gui/RegionHeightCoverage.java | 254 +++++++++++++ .../iris/core/gui/RegionInspectorPanel.java | 184 ++++++++++ .../volmit/iris/core/gui/RegionNavigator.java | 173 +++++++++ .../volmit/iris/core/gui/VisionOverlay.java | 13 +- .../iris/core/gui/VisionRenderController.java | 22 +- .../core/gui/components/IrisRenderer.java | 43 ++- .../iris/core/gui/components/MapLayer.java | 43 +++ .../iris/core/gui/components/RenderType.java | 2 +- .../iris/engine/HybridBiomeSelector.java | 22 +- .../com/volmit/iris/engine/IrisComplex.java | 126 +++++-- .../volmit/iris/engine/object/IrisBiome.java | 2 +- 18 files changed, 1491 insertions(+), 90 deletions(-) create mode 100644 core/src/main/java/com/volmit/iris/core/gui/RegionExplorer.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/RegionHeightChart.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/RegionHeightCoverage.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/RegionInspectorPanel.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/RegionNavigator.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/components/MapLayer.java diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java index 3acee1ce5..1752a8b21 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java @@ -21,6 +21,7 @@ package com.volmit.iris.core.gui; import com.volmit.iris.Iris; import com.volmit.iris.core.gui.components.IrisRenderer; import com.volmit.iris.core.gui.components.MapLabels; +import com.volmit.iris.core.gui.components.MapLayer; import com.volmit.iris.core.gui.components.MapStyle; import com.volmit.iris.core.gui.components.RenderType; import com.volmit.iris.engine.framework.Engine; @@ -95,6 +96,7 @@ final class MapCanvas extends JPanel { private boolean tileLayerStale = true; private MapStyle style = MapStyle.PLAIN; private RenderType currentType = RenderType.BIOME; + private String previewRegionKey; private String viewLabel = MapView.BIOMES.label(); private List players = List.of(); private List entities = List.of(); @@ -197,16 +199,41 @@ final class MapCanvas extends JPanel { return; } viewLabel = view.label(); - if (view.renderType() == currentType) { + RenderType nextType = view == MapView.REGIONS && previewRegionKey != null ? RenderType.REGION_PREVIEW : view.renderType(); + if (nextType == currentType) { return; } - currentType = view.renderType(); + currentType = nextType; if (currentType != RenderType.HEIGHT) { style = style.withHeightRange(null); } requestRender(); } + /** + * Shows the given region's biomes everywhere in the region view, as if every spot belonged to it; null shows the + * regions themselves again. + */ + void previewRegion(String regionKey) { + if (Objects.equals(regionKey, previewRegionKey)) { + return; + } + previewRegionKey = regionKey; + if (currentType == RenderType.REGION || currentType == RenderType.REGION_PREVIEW) { + currentType = regionKey == null ? RenderType.REGION : RenderType.REGION_PREVIEW; + requestRender(); + } + } + + MapCensus.ViewArea viewArea() { + return new MapCensus.ViewArea(screenToWorldX(0D), screenToWorldZ(0D), screenToWorldX(getWidth()), + screenToWorldZ(getHeight())); + } + + private MapLayer currentLayer() { + return currentType == RenderType.REGION_PREVIEW ? MapLayer.regionPreview(previewRegionKey) : MapLayer.of(currentType); + } + String legendTitle() { return currentType == RenderType.REGION ? "Regions in view" : "Biomes in view"; } @@ -233,6 +260,7 @@ final class MapCanvas extends JPanel { requestRender(); notifyUser("Refreshing"); listener.onTogglesChanged(); + listener.onContentRefreshed(); } void toggleShading() { @@ -922,8 +950,7 @@ final class MapCanvas extends JPanel { } IrisRenderer legendRenderer = renderer; RenderType type = currentType; - MapCensus.ViewArea area = new MapCensus.ViewArea(screenToWorldX(0D), screenToWorldZ(0D), - screenToWorldX(getWidth()), screenToWorldZ(getHeight())); + MapCensus.ViewArea area = viewArea(); List shares = MapCensus.count(renderFrame, area, label -> legendRenderer.legendColor(label, type)); listener.onLegendChanged(legendTitle(), shares, style.highlight()); if (type == RenderType.HEIGHT) { @@ -952,7 +979,7 @@ final class MapCanvas extends JPanel { clearHover(); VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec( renderer, - currentType, + currentLayer(), contentRevision, centerX, centerZ, @@ -1052,7 +1079,7 @@ final class MapCanvas extends JPanel { double worldZ = screenToWorldZ(point.y); controller.submitProbe( frame, - () -> MapHoverProbe.sample(probeEngine, worldX, worldZ), + () -> MapHoverProbe.sample(probeEngine, worldX, worldZ, previewedRegion()), details -> { QuickHover quick = quickHover; if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) { @@ -1063,6 +1090,10 @@ final class MapCanvas extends JPanel { ); } + private String previewedRegion() { + return currentType == RenderType.REGION_PREVIEW ? previewRegionKey : null; + } + // The drawn tiles already know the biome and region under the cursor, so the card follows the mouse without waiting. private void showQuickHover() { Point point = cursor; @@ -1161,10 +1192,10 @@ final class MapCanvas extends JPanel { } double worldX = screenToWorldX(point.x); double worldZ = screenToWorldZ(point.y); - RenderType type = currentType; + MapLayer layer = currentLayer(); controller.submitProbe( renderFrame, - () -> activeOverlay.openInEditor(worldX, worldZ, type), + () -> activeOverlay.openInEditor(worldX, worldZ, layer), opened -> opened.ifPresent(fileName -> notifyUser("Opened " + fileName)) ); } @@ -1187,6 +1218,8 @@ final class MapCanvas extends JPanel { interface Listener { void onTogglesChanged(); + void onContentRefreshed(); + void onShowPalettesAt(double worldX, double worldZ); void onLegendChanged(String title, List shares, MapStyle.Highlight highlight); diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java index e4cf8f135..66601522c 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java @@ -21,6 +21,7 @@ package com.volmit.iris.core.gui; import com.volmit.iris.core.gui.components.MapLabels; import com.volmit.iris.core.gui.components.RenderType; import com.volmit.iris.core.loader.IrisRegistrant; +import com.volmit.iris.engine.HybridBiomeSelector; import com.volmit.iris.engine.object.IrisBiome; import java.io.File; @@ -91,6 +92,26 @@ final class MapHoverCard { if (details.vanillaBiome() != null) { lines.add("Minecraft biome " + details.vanillaBiome().replace('_', ' ')); } + if (details.placement() != null) { + lines.add(placementText(details.placement())); + } + } + + // How hybrid terrain chose the base biome; a child biome or a coast can still replace it afterwards. + private static String placementText(HybridBiomeSelector.Choice choice) { + String height = "Picked at " + heightAboveSea(choice.placementHeight()) + " · "; + if (choice.fallback()) { + return height + "no range covers it, closest range wins"; + } + if (choice.rivalCount() == 0) { + return height + "the only biome in range"; + } + return height + "beat " + choice.rivalCount() + (choice.rivalCount() == 1 ? " other biome" : " other biomes") + " in range"; + } + + private static String heightAboveSea(double height) { + long rounded = Math.round(height); + return rounded < 0 ? -rounded + " below sea" : rounded + " above sea"; } private static void addSource(List lines, MapLabels.Label label) { diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java index 36b0d1c65..4a8c4b5f9 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java @@ -18,6 +18,7 @@ package com.volmit.iris.core.gui; +import com.volmit.iris.engine.HybridBiomeSelector; import com.volmit.iris.engine.IrisComplex; import com.volmit.iris.engine.framework.Engine; import com.volmit.iris.engine.object.InferredType; @@ -38,11 +39,17 @@ final class MapHoverProbe { private MapHoverProbe() { } - static Details sample(Engine engine, double worldX, double worldZ) { + /** + * @param previewRegionKey the region the region preview treats every spot as part of, or null for the real world + */ + static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey) { IrisComplex complex = engine.getComplex(); - IrisBiome surfaceBiome = complex.getBaseBiomeStream().get(worldX, worldZ); + IrisRegion previewRegion = previewRegionKey == null ? null : engine.getData().getRegionLoader().load(previewRegionKey); + IrisRegion region = previewRegion != null ? previewRegion : complex.getRegionStream().get(worldX, worldZ); + IrisBiome surfaceBiome = previewRegion != null + ? complex.surfaceBiomeAsIfIn(previewRegion, worldX, worldZ) + : complex.getBaseBiomeStream().get(worldX, worldZ); IrisBiome caveBiome = complex.getCaveBiomeStream().get(worldX, worldZ); - IrisRegion region = complex.getRegionStream().get(worldX, worldZ); InferredType terrainKind = complex.getBridgeStream().get(worldX, worldZ); Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ); double height = complex.getHeightStream().getDouble(worldX, worldZ); @@ -58,7 +65,8 @@ final class MapHoverProbe { vanillaBiome == null ? null : vanillaBiome.getKey().getKey(), height, dimension == null ? 0D : dimension.getFluidHeight(), - Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)) + Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)), + complex.explainHybridChoice(region, worldX, worldZ) ); } @@ -84,7 +92,8 @@ final class MapHoverProbe { String vanillaBiome, double height, double fluidHeight, - double slopeDegrees + double slopeDegrees, + HybridBiomeSelector.Choice placement ) { } } 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 index ac130e3cc..72690f0f6 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapView.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapView.java @@ -27,9 +27,10 @@ import java.awt.event.KeyEvent; */ 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), + REGIONS("Regions", "The region each spot belongs to; pick one to see which biome covers which height", + 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", + CONTINENTS("Continents", "Land, coast or sea: where hybrid terrain ends up, or each biome's first height generator otherwise", 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), diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java index a701b8460..809e6e0af 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java @@ -78,6 +78,7 @@ public final class MapWindow implements MapCanvas.Listener { private final JPanel inspectorSlot = new JPanel(new BorderLayout()); private final JComponent viewListScroll; private final JSplitPane explorerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + private final JSplitPane regionInspectorSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); private final List mapControls = new ArrayList<>(); private final List explorerControls = new ArrayList<>(); private final List noiseControls = new ArrayList<>(); @@ -90,6 +91,7 @@ public final class MapWindow implements MapCanvas.Listener { private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)"); private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)"); private final MapLegendPanel legendPanel; + private final RegionExplorer regionExplorer; private NoiseExplorerGUI paletteExplorer; private final Map objectExplorers = new EnumMap<>(MapView.class); @@ -106,6 +108,7 @@ public final class MapWindow implements MapCanvas.Listener { this.viewList = new MapViewList(this::showView); this.viewListScroll = StudioToolbar.scroll(viewList); this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label())); + this.regionExplorer = new RegionExplorer(canvas::engine, canvas::previewRegion); canvas.centerOn(start.centerX(), start.centerZ()); buildFrame(); } @@ -132,6 +135,7 @@ public final class MapWindow implements MapCanvas.Listener { private void buildFrame() { styleExplorerSplit(); + styleRegionInspectorSplit(); content.add(canvas, MAP_CARD); statusBars.add(canvas.statusBar(), MAP_CARD); sidebar.add(viewListScroll, BorderLayout.CENTER); @@ -221,13 +225,38 @@ public final class MapWindow implements MapCanvas.Listener { canvas.showView(view); canvas.setActive(true); showControls(mapControls); - useSidebar(viewListScroll); inspectorSlot.removeAll(); - inspectorSlot.add(legendPanel, BorderLayout.CENTER); + if (view == MapView.REGIONS) { + showRegionExplorer(previousView); + } else { + useSidebar(viewListScroll); + inspectorSlot.add(legendPanel, BorderLayout.CENTER); + } showCard(MAP_CARD); canvas.requestFocusInWindow(); } + // The region inspector sits above the legend, which then lists the chosen region's biomes on the map. + private void showRegionExplorer(MapView previousView) { + if (previousView != MapView.REGIONS) { + regionExplorer.loadRegions(); + } + useSidebar(viewListAbove(regionExplorer.navigator())); + regionInspectorSplit.setTopComponent(regionExplorer.inspector()); + regionInspectorSplit.setBottomComponent(legendPanel); + inspectorSlot.add(regionInspectorSplit, BorderLayout.CENTER); + EventQueue.invokeLater(() -> viewList.select(currentView)); + regionExplorer.measure(canvas.viewArea()); + } + + private void styleRegionInspectorSplit() { + regionInspectorSplit.setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR)); + regionInspectorSplit.setResizeWeight(0.7D); + regionInspectorSplit.setBackground(StudioTheme.PANEL_BACKGROUND); + regionInspectorSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT)); + regionInspectorSplit.putClientProperty("FlatLaf.style", "style: grip"); + } + private void showPalettes(MapView previousView) { boolean firstOpen = paletteExplorer == null; if (firstOpen) { @@ -389,6 +418,14 @@ public final class MapWindow implements MapCanvas.Listener { @Override public void onLegendChanged(String title, List shares, MapStyle.Highlight highlight) { legendPanel.show(title, shares, highlight); + if (currentView == MapView.REGIONS) { + regionExplorer.measure(canvas.viewArea()); + } + } + + @Override + public void onContentRefreshed() { + regionExplorer.refresh(); } private void resetExplorerView() { @@ -486,6 +523,7 @@ public final class MapWindow implements MapCanvas.Listener { private void close() { canvas.close(); + regionExplorer.close(); if (paletteExplorer != null) { paletteExplorer.close(); } diff --git a/core/src/main/java/com/volmit/iris/core/gui/RegionExplorer.java b/core/src/main/java/com/volmit/iris/core/gui/RegionExplorer.java new file mode 100644 index 000000000..d9cc40b85 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/RegionExplorer.java @@ -0,0 +1,218 @@ +/* + * 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.RegionHeightCoverage.BiomeRange; +import com.volmit.iris.core.gui.RegionNavigator.RegionRow; +import com.volmit.iris.core.gui.components.RenderType; +import com.volmit.iris.engine.IrisComplex; +import com.volmit.iris.engine.framework.Engine; +import com.volmit.iris.engine.object.InferredType; +import com.volmit.iris.engine.object.IrisBiome; +import com.volmit.iris.engine.object.IrisRegion; + +import java.awt.EventQueue; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * The region view's explorer: choosing a region fills the map with its biomes and measures which of them covers which + * ground height in view. Measuring runs off the Swing thread and only the newest request counts. + */ +final class RegionExplorer { + private static final int GROUND_SAMPLES_PER_SIDE = 96; + private static final List PLACED_TYPES = List.of(InferredType.LAND, InferredType.SHORE, InferredType.SEA); + + private final Supplier engineSource; + private final Consumer onPreviewChanged; + private final RegionNavigator navigator = new RegionNavigator(this::choose); + private final RegionInspectorPanel inspector = new RegionInspectorPanel(); + private final ThreadPoolExecutor measurer = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(1), RegionExplorer::daemonThread, new ThreadPoolExecutor.DiscardOldestPolicy()); + private final AtomicLong measurementGeneration = new AtomicLong(); + private String chosenRegionKey; + private MapCensus.ViewArea latestArea; + private MapCensus.ViewArea measuredArea; + + /** + * @param onPreviewChanged receives the region to fill the map with, or null to show every region again + */ + RegionExplorer(Supplier engineSource, Consumer onPreviewChanged) { + this.engineSource = engineSource; + this.onPreviewChanged = onPreviewChanged; + } + + RegionNavigator navigator() { + return navigator; + } + + RegionInspectorPanel inspector() { + return inspector; + } + + void loadRegions() { + Engine engine = engineSource.get(); + if (engine == null || engine.getComplex() == null) { + return; + } + List rows = new ArrayList<>(); + for (IrisRegion region : engine.getData().getRegionLoader().loadAll(engine.getDimension().getRegions())) { + rows.add(new RegionRow(region.getLoadKey(), region.getName(), + region.getColor(engine.getComplex(), RenderType.REGION).getRGB() & 0xFFFFFF)); + } + navigator.showRegions(rows); + } + + /** + * Measures the chosen region against the ground in this area, unless that was already done. + */ + void measure(MapCensus.ViewArea area) { + latestArea = area; + if (chosenRegionKey == null || area == null || area.equals(measuredArea)) { + return; + } + measuredArea = area; + String regionKey = chosenRegionKey; + long generation = measurementGeneration.incrementAndGet(); + measurer.execute(() -> { + Measurement measurement = measureSafely(regionKey, area); + EventQueue.invokeLater(() -> { + if (generation == measurementGeneration.get() && measurement != null) { + show(measurement); + } + }); + }); + } + + /** + * The pack changed, so the regions and their biomes are read again. + */ + void refresh() { + loadRegions(); + measuredArea = null; + measure(latestArea); + } + + void close() { + measurer.shutdownNow(); + } + + private void choose(String regionKey) { + chosenRegionKey = regionKey; + measuredArea = null; + measurementGeneration.incrementAndGet(); + onPreviewChanged.accept(regionKey); + if (regionKey == null) { + inspector.showNothingChosen(); + return; + } + inspector.showMeasuring(regionName(regionKey)); + measure(latestArea); + } + + private String regionName(String regionKey) { + Engine engine = engineSource.get(); + IrisRegion region = engine == null ? null : engine.getData().getRegionLoader().load(regionKey); + return region == null ? regionKey : region.getName(); + } + + private void show(Measurement measurement) { + if (!Objects.equals(measurement.regionKey(), chosenRegionKey)) { + return; + } + if (measurement.coverage() == null) { + inspector.showBiomeTerrain(measurement.regionName()); + } else { + inspector.showCoverage(measurement.regionName(), measurement.coverage()); + } + } + + private Measurement measureSafely(String regionKey, MapCensus.ViewArea area) { + try { + return measureRegion(regionKey, area); + } catch (Throwable error) { + Iris.reportError(error); + return null; + } + } + + private Measurement measureRegion(String regionKey, MapCensus.ViewArea area) { + Engine engine = engineSource.get(); + IrisComplex complex = engine == null ? null : engine.getComplex(); + IrisRegion region = complex == null ? null : engine.getData().getRegionLoader().load(regionKey); + if (region == null) { + return null; + } + if (!complex.isHybridTerrain()) { + return new Measurement(regionKey, region.getName(), null); + } + RegionHeightCoverage coverage = RegionHeightCoverage.measure(biomeRanges(engine, region), groundHeights(complex, area)); + return new Measurement(regionKey, region.getName(), coverage); + } + + private static List biomeRanges(Engine engine, IrisRegion region) { + List ranges = new ArrayList<>(); + for (InferredType type : PLACED_TYPES) { + for (IrisBiome biome : region.getBiomes(engine.getComplex(), type)) { + if (biome == null) { + continue; + } + ranges.add(new BiomeRange(biome.getLoadKey(), biome.getName(), + biome.getColor(engine, RenderType.BIOME).getRGB() & 0xFFFFFF, type, + biome.getPlacement().getMinHeight(), biome.getPlacement().getMaxHeight())); + } + } + return ranges; + } + + // In hybrid terrain the overlay noise is the ground's height above the fluid, before any biome adds its own terrain. + private static int[] groundHeights(IrisComplex complex, MapCensus.ViewArea area) { + int[] heights = new int[GROUND_SAMPLES_PER_SIDE * GROUND_SAMPLES_PER_SIDE]; + double stepX = (area.maximumX() - area.minimumX()) / GROUND_SAMPLES_PER_SIDE; + double stepZ = (area.maximumZ() - area.minimumZ()) / GROUND_SAMPLES_PER_SIDE; + for (int row = 0; row < GROUND_SAMPLES_PER_SIDE; row++) { + for (int column = 0; column < GROUND_SAMPLES_PER_SIDE; column++) { + double x = area.minimumX() + (column + 0.5D) * stepX; + double z = area.minimumZ() + (row + 0.5D) * stepZ; + heights[row * GROUND_SAMPLES_PER_SIDE + column] = (int) Math.round(complex.getOverlayStream().get(x, z)); + } + } + return heights; + } + + private static Thread daemonThread(Runnable task) { + Thread thread = new Thread(task, "Iris Region Explorer"); + thread.setDaemon(true); + return thread; + } + + /** + * @param coverage how the region's biomes cover the ground in view, or null when the dimension uses biome terrain + */ + private record Measurement(String regionKey, String regionName, RegionHeightCoverage coverage) { + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/RegionHeightChart.java b/core/src/main/java/com/volmit/iris/core/gui/RegionHeightChart.java new file mode 100644 index 000000000..ad851841b --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/RegionHeightChart.java @@ -0,0 +1,343 @@ +/* + * 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.RegionHeightCoverage.BiomeRange; +import com.volmit.iris.core.gui.RegionHeightCoverage.HeightBand; +import com.volmit.iris.engine.object.InferredType; + +import javax.swing.JComponent; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.event.MouseEvent; +import java.util.List; + +/** + * Ground height runs left to right. On top, how much of the ground in view sits at each height; below, one bar per + * biome over the heights it allows. Heights no biome covers are red, heights several ranged biomes share are hatched. + */ +final class RegionHeightChart extends JComponent { + private static final long serialVersionUID = 6218894112903760545L; + private static final List ROW_GROUPS = List.of(InferredType.LAND, InferredType.SHORE, InferredType.SEA); + private static final int CHART_WIDTH = 330; + private static final int PADDING = 12; + private static final int NAME_COLUMN = 104; + private static final int NAME_GAP = 8; + private static final int HISTOGRAM_HEIGHT = 54; + private static final int AXIS_HEIGHT = 20; + private static final int GROUP_HEADER_HEIGHT = 22; + private static final int ROW_HEIGHT = 18; + private static final int BAR_HEIGHT = 10; + private static final int MINIMUM_TICK_SPACING = 38; + private static final int[] TICK_STEPS = {5, 10, 20, 25, 50, 100, 200, 250, 500, 1000}; + private static final int HATCH_SPACING = 5; + private static final double RANGE_PADDING_FRACTION = 0.04D; + private static final Color LAND_GROUND = new ThemeColor(0x9DA0A8, 0x6F737A); + private static final Color SEA_GROUND = new ThemeColor(0x7FA3D9, 0x4A6FA8); + private static final Color GAP_FILL = new ThemeColor(new Color(0xDB, 0x3B, 0x4B, 46), new Color(0xF7, 0x54, 0x64, 52)); + private static final Color SHARED_HATCH = new ThemeColor(new Color(0, 0, 0, 70), new Color(255, 255, 255, 70)); + private static final Color SEA_LEVEL = new ThemeColor(new Color(0x31, 0x5F, 0xBD, 150), new Color(0x54, 0x8A, 0xF7, 150)); + + private RegionHeightCoverage coverage; + private int lowestShown; + private int highestShown; + + RegionHeightChart() { + setToolTipText(""); + setOpaque(false); + } + + void show(RegionHeightCoverage nextCoverage) { + coverage = nextCoverage; + fitShownHeights(); + revalidate(); + repaint(); + } + + // The chart spans the ground in view and every range edge, so a biome placed above or below the view still shows. + private void fitShownHeights() { + if (coverage == null) { + return; + } + int lowest = Math.min(0, coverage.lowestHeight()); + int highest = Math.max(0, coverage.highestHeight()); + for (InferredType type : ROW_GROUPS) { + for (BiomeRange range : coverage.ranges(type)) { + if (range.hasLowerBound()) { + lowest = Math.min(lowest, range.minHeight()); + } + if (range.hasUpperBound()) { + highest = Math.max(highest, range.maxHeight()); + } + } + } + int padding = Math.max(2, (int) Math.round((highest - lowest) * RANGE_PADDING_FRACTION)); + lowestShown = lowest - padding; + highestShown = highest + padding; + } + + @Override + public Dimension getPreferredSize() { + int rows = 0; + int groups = 0; + if (coverage != null) { + for (InferredType type : ROW_GROUPS) { + int count = coverage.ranges(type).size(); + rows += count; + groups += count > 0 ? 1 : 0; + } + } + int height = PADDING * 2 + HISTOGRAM_HEIGHT + AXIS_HEIGHT + groups * GROUP_HEADER_HEIGHT + rows * ROW_HEIGHT; + return StudioTheme.scaledSize(CHART_WIDTH, height); + } + + @Override + public Dimension getMaximumSize() { + return new Dimension(Integer.MAX_VALUE, getPreferredSize().height); + } + + @Override + protected void paintComponent(Graphics graphics) { + if (coverage == null) { + return; + } + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + int top = StudioTheme.scale(PADDING); + int rowsTop = top + StudioTheme.scale(HISTOGRAM_HEIGHT + AXIS_HEIGHT); + int bottom = getHeight() - StudioTheme.scale(PADDING); + drawBands(canvas, top, bottom); + drawGround(canvas, top); + drawAxis(canvas, top + StudioTheme.scale(HISTOGRAM_HEIGHT)); + drawRows(canvas, rowsTop); + drawSeaLevel(canvas, top, bottom); + } finally { + canvas.dispose(); + } + } + + private void drawBands(Graphics2D canvas, int top, int bottom) { + int histogramBottom = top + StudioTheme.scale(HISTOGRAM_HEIGHT); + for (InferredType type : RegionHeightCoverage.MEASURED_TYPES) { + for (HeightBand band : coverage.bands(type)) { + int left = xOf(band.fromHeight()); + int width = Math.max(1, xOf(band.toHeight() + 1) - left); + if (band.isGap()) { + canvas.setColor(GAP_FILL); + canvas.fillRect(left, top, width, bottom - top); + } else if (band.isShared() && band.biomes().stream().allMatch(BiomeRange::isBounded)) { + hatch(canvas, new Rectangle(left, top, width, histogramBottom - top)); + } + } + } + } + + private static void hatch(Graphics2D canvas, Rectangle area) { + Shape previousClip = canvas.getClip(); + canvas.clip(area); + canvas.setColor(SHARED_HATCH); + int spacing = StudioTheme.scale(HATCH_SPACING); + for (int offset = -area.height; offset < area.width; offset += spacing) { + canvas.drawLine(area.x + offset, area.y + area.height, area.x + offset + area.height, area.y); + } + canvas.setClip(previousClip); + } + + private void drawGround(Graphics2D canvas, int top) { + int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT); + int plotLeft = plotLeft(); + int plotRight = plotRight(); + int[] columnCounts = new int[plotRight - plotLeft]; + int highestColumn = 1; + for (int column = 0; column < columnCounts.length; column++) { + int fromHeight = heightAt(plotLeft + column); + int toHeight = Math.max(fromHeight, heightAt(plotLeft + column + 1) - 1); + for (int height = fromHeight; height <= toHeight; height++) { + columnCounts[column] += coverage.count(height); + } + highestColumn = Math.max(highestColumn, columnCounts[column]); + } + for (int column = 0; column < columnCounts.length; column++) { + if (columnCounts[column] == 0) { + continue; + } + int barHeight = Math.max(1, (int) Math.round(columnCounts[column] / (double) highestColumn * histogramHeight)); + canvas.setColor(heightAt(plotLeft + column) < 0 ? SEA_GROUND : LAND_GROUND); + canvas.fillRect(plotLeft + column, top + histogramHeight - barHeight, 1, barHeight); + } + } + + private void drawAxis(Graphics2D canvas, int axisTop) { + canvas.setFont(StudioTheme.smallFont()); + FontMetrics metrics = canvas.getFontMetrics(); + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.drawLine(plotLeft(), axisTop, plotRight(), axisTop); + int step = tickStep(); + int firstTick = Math.floorDiv(lowestShown, step) * step + step; + for (int height = firstTick; height < highestShown; height += step) { + int x = xOf(height); + String label = height > 0 ? "+" + height : String.valueOf(height); + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.drawLine(x, axisTop, x, axisTop + StudioTheme.scale(3)); + canvas.setColor(StudioTheme.MUTED_TEXT); + canvas.drawString(label, x - metrics.stringWidth(label) / 2, axisTop + StudioTheme.scale(4) + metrics.getAscent()); + } + canvas.setColor(StudioTheme.MUTED_TEXT); + canvas.drawString("Ground in view", StudioTheme.scale(PADDING), axisTop - StudioTheme.scale(4)); + } + + private int tickStep() { + double pixelsPerBlock = (plotRight() - plotLeft()) / (double) Math.max(1, highestShown - lowestShown); + for (int step : TICK_STEPS) { + if (step * pixelsPerBlock >= StudioTheme.scale(MINIMUM_TICK_SPACING)) { + return step; + } + } + return TICK_STEPS[TICK_STEPS.length - 1]; + } + + private void drawRows(Graphics2D canvas, int rowsTop) { + int y = rowsTop; + for (InferredType type : ROW_GROUPS) { + List ranges = coverage.ranges(type); + if (ranges.isEmpty()) { + continue; + } + canvas.setFont(StudioTheme.sectionFont()); + canvas.setColor(StudioTheme.MUTED_TEXT); + canvas.drawString(groupTitle(type), StudioTheme.scale(PADDING), y + StudioTheme.scale(GROUP_HEADER_HEIGHT) - StudioTheme.scale(7)); + y += StudioTheme.scale(GROUP_HEADER_HEIGHT); + for (BiomeRange range : ranges) { + drawRow(canvas, range, y); + y += StudioTheme.scale(ROW_HEIGHT); + } + } + } + + private void drawRow(Graphics2D canvas, BiomeRange range, int rowTop) { + int rowHeight = StudioTheme.scale(ROW_HEIGHT); + canvas.setFont(StudioTheme.smallFont()); + FontMetrics metrics = canvas.getFontMetrics(); + canvas.setColor(StudioTheme.SECONDARY_TEXT); + canvas.drawString(ellipsize(range.name(), metrics, StudioTheme.scale(NAME_COLUMN)), StudioTheme.scale(PADDING), + rowTop + (rowHeight + metrics.getAscent() - metrics.getDescent()) / 2); + int left = xOf(Math.max(range.minHeight(), lowestShown)); + int right = xOf(Math.min(range.maxHeight(), highestShown) + 1); + int barHeight = StudioTheme.scale(BAR_HEIGHT); + int barTop = rowTop + (rowHeight - barHeight) / 2; + int arc = StudioTheme.scale(4); + canvas.setColor(new Color(range.color())); + canvas.fillRoundRect(left, barTop, Math.max(2, right - left), barHeight, arc, arc); + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.drawRoundRect(left, barTop, Math.max(2, right - left) - 1, barHeight - 1, arc, arc); + } + + private void drawSeaLevel(Graphics2D canvas, int top, int bottom) { + int x = xOf(0); + canvas.setColor(SEA_LEVEL); + float dash = StudioTheme.scale(3); + canvas.setStroke(new BasicStroke(1F, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10F, new float[]{dash, dash}, 0F)); + canvas.drawLine(x, top, x, bottom); + } + + @Override + public String getToolTipText(MouseEvent event) { + if (coverage == null) { + return null; + } + BiomeRange row = rowAt(event.getY()); + if (row != null) { + return row.name() + ": " + (row.isBounded() ? RegionHeightCoverage.heightText(row.minHeight(), row.maxHeight()) : "any height"); + } + int height = heightAt(event.getX()); + for (InferredType type : RegionHeightCoverage.MEASURED_TYPES) { + for (HeightBand band : coverage.bands(type)) { + if (band.isGap() && height >= band.fromHeight() && height <= band.toHeight()) { + return "No biome covers " + RegionHeightCoverage.heightText(band.fromHeight(), band.toHeight()); + } + } + } + return null; + } + + private BiomeRange rowAt(int y) { + int rowTop = StudioTheme.scale(PADDING + HISTOGRAM_HEIGHT + AXIS_HEIGHT); + for (InferredType type : ROW_GROUPS) { + List ranges = coverage.ranges(type); + if (ranges.isEmpty()) { + continue; + } + rowTop += StudioTheme.scale(GROUP_HEADER_HEIGHT); + for (BiomeRange range : ranges) { + if (y >= rowTop && y < rowTop + StudioTheme.scale(ROW_HEIGHT)) { + return range; + } + rowTop += StudioTheme.scale(ROW_HEIGHT); + } + } + return null; + } + + private int plotLeft() { + return StudioTheme.scale(PADDING + NAME_COLUMN + NAME_GAP); + } + + private int plotRight() { + return Math.max(plotLeft() + 1, getWidth() - StudioTheme.scale(PADDING)); + } + + private int xOf(int height) { + double fraction = (height - lowestShown) / (double) Math.max(1, highestShown - lowestShown); + return plotLeft() + (int) Math.round(fraction * (plotRight() - plotLeft())); + } + + private int heightAt(int x) { + double fraction = (x - plotLeft()) / (double) Math.max(1, plotRight() - plotLeft()); + return (int) Math.floor(lowestShown + fraction * (highestShown - lowestShown)); + } + + private static String groupTitle(InferredType type) { + return switch (type) { + case LAND -> "LAND"; + case SHORE -> "SHORE"; + default -> "SEA"; + }; + } + + private static String ellipsize(String text, FontMetrics metrics, int maximumWidth) { + if (metrics.stringWidth(text) <= maximumWidth) { + return text; + } + String ellipsis = "…"; + int end = text.length(); + while (end > 0 && metrics.stringWidth(text.substring(0, end) + ellipsis) > maximumWidth) { + end--; + } + return text.substring(0, end) + ellipsis; + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/RegionHeightCoverage.java b/core/src/main/java/com/volmit/iris/core/gui/RegionHeightCoverage.java new file mode 100644 index 000000000..c54b042a8 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/RegionHeightCoverage.java @@ -0,0 +1,254 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import com.volmit.iris.engine.object.InferredType; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +/** + * Which of a region's biomes allow which ground height in HYBRID terrain, measured against the ground heights the map + * shows, so the region view can point out heights no biome covers and heights several biomes share. + * Heights are in blocks above the fluid height, negative below it. + */ +final class RegionHeightCoverage { + static final List MEASURED_TYPES = List.of(InferredType.SEA, InferredType.LAND); + private static final int MAXIMUM_NOTES = 8; + + private final List ranges; + private final int lowestHeight; + private final int highestHeight; + private final int[] countsFromLowest; + private final int highestCount; + + private RegionHeightCoverage(List ranges, int lowestHeight, int[] countsFromLowest) { + this.ranges = List.copyOf(ranges); + this.lowestHeight = lowestHeight; + this.highestHeight = lowestHeight + countsFromLowest.length - 1; + this.countsFromLowest = countsFromLowest; + int highest = 0; + for (int count : countsFromLowest) { + highest = Math.max(highest, count); + } + this.highestCount = highest; + } + + /** + * @param groundHeights the ground height of every sampled spot, rounded to whole blocks + */ + static RegionHeightCoverage measure(List ranges, int[] groundHeights) { + if (groundHeights.length == 0) { + return new RegionHeightCoverage(ranges, 0, new int[1]); + } + int lowest = Integer.MAX_VALUE; + int highest = Integer.MIN_VALUE; + for (int height : groundHeights) { + lowest = Math.min(lowest, height); + highest = Math.max(highest, height); + } + int[] counts = new int[highest - lowest + 1]; + for (int height : groundHeights) { + counts[height - lowest]++; + } + return new RegionHeightCoverage(ranges, lowest, counts); + } + + List ranges(InferredType type) { + return ranges.stream().filter(range -> range.type() == type).toList(); + } + + int lowestHeight() { + return lowestHeight; + } + + int highestHeight() { + return highestHeight; + } + + int count(int height) { + return height < lowestHeight || height > highestHeight ? 0 : countsFromLowest[height - lowestHeight]; + } + + int highestCount() { + return highestCount; + } + + /** + * The ground heights of one type split into runs that the same biomes allow, from low to high. Land is the ground + * at or above the fluid height, sea the ground below it. + */ + List bands(InferredType type) { + int from = type == InferredType.SEA ? lowestHeight : Math.max(0, lowestHeight); + int to = type == InferredType.SEA ? Math.min(-1, highestHeight) : highestHeight; + int total = countBetween(from, to); + List bands = new ArrayList<>(); + if (total == 0) { + return bands; + } + List typeRanges = ranges(type); + int bandStart = from; + List bandBiomes = coveringBiomes(typeRanges, from); + for (int height = from + 1; height <= to + 1; height++) { + List biomes = height <= to ? coveringBiomes(typeRanges, height) : null; + if (bandBiomes.equals(biomes)) { + continue; + } + bands.add(new HeightBand(type, bandStart, height - 1, bandBiomes, countBetween(bandStart, height - 1) / (double) total)); + bandStart = height; + bandBiomes = biomes; + } + return bands; + } + + List notes() { + List notes = new ArrayList<>(); + noteBiomesWithoutRange(notes); + for (InferredType type : MEASURED_TYPES) { + for (HeightBand band : bands(type)) { + if (band.share() == 0D) { + continue; + } + if (band.isGap()) { + notes.add(gapNote(band)); + } else if (band.isShared() && band.biomes().stream().allMatch(BiomeRange::isBounded)) { + notes.add(names(band.biomes()) + " share " + heightText(band.fromHeight(), band.toHeight()) + + ": their noise decides."); + } + } + } + return notes.size() > MAXIMUM_NOTES ? notes.subList(0, MAXIMUM_NOTES) : notes; + } + + private void noteBiomesWithoutRange(List notes) { + List unbounded = ranges.stream() + .filter(range -> range.type() != InferredType.SHORE && !range.isBounded()) + .toList(); + if (unbounded.isEmpty()) { + return; + } + boolean single = unbounded.size() == 1; + notes.add(names(unbounded) + (single ? " has no height range, so it" : " have no height range, so they") + + " can show up at any height."); + } + + private String gapNote(HeightBand gap) { + String ground = gap.type() == InferredType.SEA ? "sea floor" : "land"; + String note = "Nothing covers " + heightText(gap.fromHeight(), gap.toHeight()) + " (" + percent(gap.share()) + + " of the " + ground + " in view)"; + BiomeRange below = closestBelow(gap); + BiomeRange above = closestAbove(gap); + if (below != null && above != null) { + return note + ": " + below.name() + " and " + above.name() + ", the closest ranges, take it over."; + } + BiomeRange closest = below != null ? below : above; + return closest == null ? note + "." : note + ": " + closest.name() + ", the closest range, takes it over."; + } + + private BiomeRange closestBelow(HeightBand gap) { + return ranges(gap.type()).stream() + .filter(range -> range.maxHeight() < gap.fromHeight()) + .max(Comparator.comparingInt(BiomeRange::maxHeight)) + .orElse(null); + } + + private BiomeRange closestAbove(HeightBand gap) { + return ranges(gap.type()).stream() + .filter(range -> range.minHeight() > gap.toHeight()) + .min(Comparator.comparingInt(BiomeRange::minHeight)) + .orElse(null); + } + + private static List coveringBiomes(List typeRanges, int height) { + return typeRanges.stream().filter(range -> range.covers(height)).toList(); + } + + private int countBetween(int from, int to) { + int total = 0; + for (int height = from; height <= to; height++) { + total += count(height); + } + return total; + } + + static String heightText(int from, int to) { + if (from >= 0) { + return from == to ? from + " above sea" : from + " to " + to + " above sea"; + } + if (to < 0) { + return from == to ? -from + " below sea" : -to + " to " + -from + " below sea"; + } + return -from + " below to " + to + " above sea"; + } + + private static String names(List biomes) { + List names = biomes.stream().map(BiomeRange::name).toList(); + if (names.size() == 1) { + return names.get(0); + } + return names.subList(0, names.size() - 1).stream().collect(Collectors.joining(", ")) + " and " + names.get(names.size() - 1); + } + + private static String percent(double share) { + double percent = share * 100D; + return percent < 1D ? "<1%" : String.format(Locale.ROOT, "%.0f%%", percent); + } + + /** + * The heights one of the region's biomes allows, from its placement. + */ + record BiomeRange(String key, String name, int color, InferredType type, int minHeight, int maxHeight) { + private static final int UNBOUNDED_LOW = -2032; + private static final int UNBOUNDED_HIGH = 2032; + + boolean covers(int height) { + return height >= minHeight && height <= maxHeight; + } + + boolean isBounded() { + return hasLowerBound() || hasUpperBound(); + } + + boolean hasLowerBound() { + return minHeight > UNBOUNDED_LOW; + } + + boolean hasUpperBound() { + return maxHeight < UNBOUNDED_HIGH; + } + } + + /** + * A run of ground heights that the same biomes allow; no biomes makes it a gap. + * + * @param share how much of this type's ground in view lies in the run + */ + record HeightBand(InferredType type, int fromHeight, int toHeight, List biomes, double share) { + boolean isGap() { + return biomes.isEmpty(); + } + + boolean isShared() { + return biomes.size() > 1; + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/RegionInspectorPanel.java b/core/src/main/java/com/volmit/iris/core/gui/RegionInspectorPanel.java new file mode 100644 index 000000000..466ee622a --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/RegionInspectorPanel.java @@ -0,0 +1,184 @@ +/* + * 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.BoxLayout; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextArea; +import javax.swing.Scrollable; +import javax.swing.SwingConstants; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.GridLayout; +import java.awt.Rectangle; +import java.util.List; + +/** + * The region view's inspector: for the chosen region, which of its biomes covers which ground height, and what that + * means in words. + */ +final class RegionInspectorPanel extends JPanel { + private static final long serialVersionUID = -2874105263188240317L; + private static final String MESSAGE_CARD = "message"; + private static final String COVERAGE_CARD = "coverage"; + private static final int PANEL_WIDTH = 330; + private static final String PARAGRAPH_BREAK = "\n\n"; + + private final JLabel title = new JLabel(); + private final JLabel detail = new JLabel(); + private final CardLayout cards = new CardLayout(); + private final JPanel cardPanel = new JPanel(cards); + private final JTextArea message = wrappingText(); + private final RegionHeightChart chart = new RegionHeightChart(); + private final JTextArea notes = wrappingText(); + + RegionInspectorPanel() { + super(new BorderLayout()); + setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0)); + setBackground(StudioTheme.PANEL_BACKGROUND); + add(buildHeader(), BorderLayout.NORTH); + cardPanel.setOpaque(false); + cardPanel.add(buildMessageCard(), MESSAGE_CARD); + cardPanel.add(StudioToolbar.scroll(buildCoverageCard()), COVERAGE_CARD); + add(cardPanel, BorderLayout.CENTER); + showNothingChosen(); + } + + void showNothingChosen() { + showHeader("Regions", "Every region in its own colour"); + showMessage("Pick a region on the left to fill the map with its biomes and see which biome covers which height."); + } + + void showMeasuring(String regionName) { + showHeader(regionName, "Measuring the ground in view…"); + } + + void showBiomeTerrain(String regionName) { + showHeader(regionName, "Biome terrain"); + showMessage("This dimension uses BIOME terrain, where biomes are placed by noise and ignore height. " + + "Set the dimension's terrainMode to HYBRID to choose biomes by the height of the ground."); + } + + void showCoverage(String regionName, RegionHeightCoverage coverage) { + showHeader(regionName, "Hybrid terrain · biomes by ground height"); + chart.show(coverage); + notes.setText(notesText(coverage.notes())); + cards.show(cardPanel, COVERAGE_CARD); + } + + private JPanel buildHeader() { + JPanel header = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(2))); + header.setOpaque(false); + header.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR), + StudioTheme.emptyBorder(10, 16, 9, 16))); + title.setFont(StudioTheme.titleFont()); + detail.setFont(StudioTheme.smallFont()); + detail.setForeground(StudioTheme.MUTED_TEXT); + header.add(title); + header.add(detail); + return header; + } + + private JPanel buildMessageCard() { + JPanel card = new JPanel(new BorderLayout()); + card.setOpaque(false); + message.setBorder(StudioTheme.emptyBorder(12, 16, 12, 16)); + card.add(message, BorderLayout.NORTH); + return card; + } + + private JPanel buildCoverageCard() { + JPanel card = new ViewportWidthPanel(); + card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS)); + card.setBackground(StudioTheme.PANEL_BACKGROUND); + chart.setAlignmentX(Component.LEFT_ALIGNMENT); + notes.setAlignmentX(Component.LEFT_ALIGNMENT); + notes.setBorder(StudioTheme.emptyBorder(0, 16, 12, 16)); + card.add(chart); + card.add(notes); + return card; + } + + private void showHeader(String regionName, String detailText) { + title.setText(regionName); + detail.setText(detailText); + } + + private void showMessage(String text) { + message.setText(text); + cards.show(cardPanel, MESSAGE_CARD); + } + + private static String notesText(List lines) { + if (lines.isEmpty()) { + return "Every ground height in view has a biome, and no two ranged biomes share one."; + } + return String.join(PARAGRAPH_BREAK, lines); + } + + private static JTextArea wrappingText() { + JTextArea text = new JTextArea(); + text.setEditable(false); + text.setFocusable(false); + text.setOpaque(false); + text.setLineWrap(true); + text.setWrapStyleWord(true); + text.setFont(StudioTheme.font()); + text.setForeground(StudioTheme.SECONDARY_TEXT); + return text; + } + + /** + * Follows the scroll pane's width, so the chart stretches to it and the notes wrap inside it. + */ + private static final class ViewportWidthPanel extends JPanel implements Scrollable { + private static final long serialVersionUID = 6086542174870301352L; + private static final int SCROLL_STEP = 18; + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) { + return StudioTheme.scale(SCROLL_STEP); + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) { + return orientation == SwingConstants.VERTICAL ? visibleRect.height : visibleRect.width; + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return false; + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/RegionNavigator.java b/core/src/main/java/com/volmit/iris/core/gui/RegionNavigator.java new file mode 100644 index 000000000..acb2c1445 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/RegionNavigator.java @@ -0,0 +1,173 @@ +/* + * 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.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * The region view's sidebar: every region of the pack, or all of them at once, to fill the map with. + */ +final class RegionNavigator extends JPanel { + private static final long serialVersionUID = 3150735486722101466L; + private static final RegionRow ALL_REGIONS = new RegionRow(null, "All regions", -1); + private static final int ROW_HEIGHT = 24; + private static final int SWATCH_SIZE = 12; + private static final int SWATCH_GAP = 8; + private static final int ROW_PADDING = 10; + + private final Consumer onRegionChosen; + private final JTextField searchField = new JTextField(); + private final DefaultListModel rows = new DefaultListModel<>(); + private final JList list = new JList<>(rows); + private List regions = List.of(); + private String chosenKey; + private boolean updatingRows; + + /** + * @param onRegionChosen receives the chosen region's key, or null for all regions + */ + RegionNavigator(Consumer onRegionChosen) { + super(new BorderLayout()); + this.onRegionChosen = onRegionChosen; + StudioToolbar.makeSearchField(searchField, "Search regions..."); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent event) { + showMatchingRows(); + } + + @Override + public void removeUpdate(DocumentEvent event) { + showMatchingRows(); + } + + @Override + public void changedUpdate(DocumentEvent event) { + showMatchingRows(); + } + }); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT)); + list.setCellRenderer(new RegionRowRenderer()); + list.addListSelectionListener(event -> { + RegionRow row = list.getSelectedValue(); + if (!event.getValueIsAdjusting() && !updatingRows && row != null && !Objects.equals(row.key(), chosenKey)) { + chosenKey = row.key(); + onRegionChosen.accept(chosenKey); + } + }); + add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH); + add(StudioToolbar.scroll(list), BorderLayout.CENTER); + showMatchingRows(); + } + + void showRegions(List nextRegions) { + regions = List.copyOf(nextRegions); + showMatchingRows(); + } + + private void showMatchingRows() { + String filter = searchField.getText().trim().toLowerCase(Locale.ROOT); + updatingRows = true; + try { + rows.clear(); + rows.addElement(ALL_REGIONS); + for (RegionRow region : regions) { + if (filter.isEmpty() || region.name().toLowerCase(Locale.ROOT).contains(filter) + || region.key().toLowerCase(Locale.ROOT).contains(filter)) { + rows.addElement(region); + } + } + selectChosenRow(); + } finally { + updatingRows = false; + } + } + + private void selectChosenRow() { + for (int index = 0; index < rows.size(); index++) { + if (Objects.equals(rows.get(index).key(), chosenKey)) { + list.setSelectedIndex(index); + return; + } + } + list.clearSelection(); + } + + /** + * @param color the region's map colour, or -1 for the all regions row + */ + record RegionRow(String key, String name, int color) { + } + + private static final class RegionRowRenderer extends DefaultListCellRenderer { + private static final long serialVersionUID = -6114522950207432516L; + private Color swatch; + + @Override + public Component getListCellRendererComponent(JList owner, Object value, int index, boolean selected, boolean focused) { + RegionRow row = (RegionRow) value; + super.getListCellRendererComponent(owner, row.name(), index, selected, false); + setFont(row.key() == null ? StudioTheme.boldFont() : StudioTheme.font()); + setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING)); + setToolTipText(row.key()); + swatch = row.color() < 0 ? null : new Color(row.color()); + return this; + } + + @Override + protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); + if (swatch == null) { + return; + } + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int size = StudioTheme.scale(SWATCH_SIZE); + int x = StudioTheme.scale(ROW_PADDING); + int y = (getHeight() - size) / 2; + int arc = StudioTheme.scale(4); + canvas.setColor(swatch); + canvas.fillRoundRect(x, y, size, size, arc, arc); + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.drawRoundRect(x, y, size - 1, size - 1, arc, arc); + } finally { + canvas.dispose(); + } + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/VisionOverlay.java b/core/src/main/java/com/volmit/iris/core/gui/VisionOverlay.java index 899489fcf..7f1b219a5 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/VisionOverlay.java +++ b/core/src/main/java/com/volmit/iris/core/gui/VisionOverlay.java @@ -19,9 +19,10 @@ package com.volmit.iris.core.gui; import com.volmit.iris.Iris; -import com.volmit.iris.core.gui.components.RenderType; +import com.volmit.iris.core.gui.components.MapLayer; import com.volmit.iris.engine.IrisComplex; import com.volmit.iris.engine.framework.Engine; +import com.volmit.iris.engine.object.IrisRegion; import com.volmit.iris.engine.object.IrisWorld; import com.volmit.iris.util.format.Form; import com.volmit.iris.util.scheduling.J; @@ -65,20 +66,26 @@ final class VisionOverlay { runOnServerThread(() -> teleportOpener(blockX, blockZ)); } - Optional openInEditor(double worldX, double worldZ, RenderType type) { + Optional openInEditor(double worldX, double worldZ, MapLayer layer) { IrisComplex complex = engine.getComplex(); - File file = switch (type) { + File file = switch (layer.type()) { case BIOME, HEIGHT -> complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode(); case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode(); + case REGION_PREVIEW -> openPreviewedBiome(complex, layer.previewRegionKey(), worldX, worldZ); case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode(); case CONTINENT -> null; }; return Optional.ofNullable(file).map(File::getName); } + private File openPreviewedBiome(IrisComplex complex, String regionKey, double worldX, double worldZ) { + IrisRegion region = engine.getData().getRegionLoader().load(regionKey); + return region == null ? null : complex.surfaceBiomeAsIfIn(region, worldX, worldZ).openInVSCode(); + } + private void queuePlayerRefresh() { if (!playerRefreshQueued.compareAndSet(false, true)) { return; 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 f21a3f2f0..9b32e0672 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 @@ -21,8 +21,8 @@ package com.volmit.iris.core.gui; import com.volmit.iris.Iris; import com.volmit.iris.core.gui.components.IrisRenderer; import com.volmit.iris.core.gui.components.MapLabels; +import com.volmit.iris.core.gui.components.MapLayer; import com.volmit.iris.core.gui.components.MapTile; -import com.volmit.iris.core.gui.components.RenderType; import java.awt.EventQueue; import java.util.ArrayList; @@ -212,7 +212,7 @@ final class VisionRenderController implements AutoCloseable { for (long tileX = minimumX; tileX <= maximumX; tileX++) { double deltaX = tileX + 0.5D - centerTileX; double deltaZ = tileZ + 0.5D - centerTileZ; - TileKey key = new TileKey(spec.contentRevision(), spec.type(), spec.heights(), renderScale, tileX, tileZ); + TileKey key = new TileKey(spec.contentRevision(), spec.layer(), spec.heights(), renderScale, tileX, tileZ); tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ)); } } @@ -302,7 +302,7 @@ final class VisionRenderController implements AutoCloseable { key.tileZ() * tileSpan, key.blocksPerPixel(), TILE_PIXELS, - key.type(), + key.layer(), key.heights(), () -> !isWanted(key) ); @@ -337,7 +337,7 @@ final class VisionRenderController implements AutoCloseable { } long parentX = Math.floorDiv(key.tileX(), scale); long parentZ = Math.floorDiv(key.tileZ(), scale); - MapTile parent = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), parentScale, parentX, + MapTile parent = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, parentX, parentZ)); if (parent != null) { int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize; @@ -357,7 +357,7 @@ final class VisionRenderController implements AutoCloseable { List pieces = new ArrayList<>(4); for (int childZ = 0; childZ < 2; childZ++) { for (int childX = 0; childX < 2; childX++) { - MapTile child = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), childScale, + MapTile child = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale, key.tileX() * 2L + childX, key.tileZ() * 2L + childZ)); if (child != null) { pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D)); @@ -420,7 +420,7 @@ final class VisionRenderController implements AutoCloseable { record RenderSpec( IrisRenderer renderer, - RenderType type, + MapLayer layer, long contentRevision, double centerX, double centerZ, @@ -431,7 +431,7 @@ final class VisionRenderController implements AutoCloseable { ) { RenderSpec { Objects.requireNonNull(renderer, "renderer"); - Objects.requireNonNull(type, "type"); + Objects.requireNonNull(layer, "layer"); if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) { throw new IllegalArgumentException("Vision center must be finite"); } @@ -492,7 +492,7 @@ final class VisionRenderController implements AutoCloseable { double tileSpan = TILE_PIXELS * renderBlocksPerPixel; long tileX = (long) Math.floor(worldX / tileSpan); long tileZ = (long) Math.floor(worldZ / tileSpan); - VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.type(), spec.heights(), + VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.layer(), spec.heights(), renderBlocksPerPixel, tileX, tileZ)); MapTile tile = visible == null ? null : visible.tile(); if (tile == null) { @@ -563,16 +563,16 @@ final class VisionRenderController implements AutoCloseable { /** * @param heights whether the tile also carries terrain heights, for shading */ - record TileKey(long contentRevision, RenderType type, boolean heights, double blocksPerPixel, long tileX, long tileZ) { + record TileKey(long contentRevision, MapLayer layer, boolean heights, double blocksPerPixel, long tileX, long tileZ) { TileKey { - Objects.requireNonNull(type, "type"); + Objects.requireNonNull(layer, "layer"); if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { throw new IllegalArgumentException("Vision cache scale must be finite and positive"); } } TileKey withHeights(boolean nextHeights) { - return new TileKey(contentRevision, type, nextHeights, blocksPerPixel, tileX, tileZ); + return new TileKey(contentRevision, layer, nextHeights, blocksPerPixel, tileX, tileZ); } } 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 5a2425f15..6838e2db8 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 @@ -76,7 +76,7 @@ public final class IrisRenderer { double startZ, double blocksPerPixel, int size, - RenderType type, + MapLayer layer, boolean withHeights, BooleanSupplier cancelled ) { @@ -86,12 +86,13 @@ public final class IrisRenderer { if (size < 1) { throw new IllegalArgumentException("Map tile size must be positive"); } - Objects.requireNonNull(type, "type"); + Objects.requireNonNull(layer, "layer"); Objects.requireNonNull(cancelled, "cancelled"); + RenderType type = layer.type(); checkCancelled(cancelled); TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled); - int[] identities = sampleIdentities(grid, type); + int[] identities = sampleIdentities(grid, layer); double[] heights = withHeights || type == RenderType.HEIGHT ? sampleHeights(grid) : null; short[] subjects = new short[identities.length]; short[] regions = new short[identities.length]; @@ -139,8 +140,8 @@ public final class IrisRenderer { return blend(ROCK, SNOW, (land - 0.78D) / 0.22D); } - private int[] sampleIdentities(TileGrid grid, RenderType type) { - IdentitySampler sampler = new IdentitySampler(grid, identityShader(type)); + private int[] sampleIdentities(TileGrid grid, MapLayer layer) { + IdentitySampler sampler = new IdentitySampler(grid, identityShader(layer)); sampler.renderAll(sampleBlockPixels(grid.step())); return sampler.identities; } @@ -155,26 +156,41 @@ public final class IrisRenderer { return Integer.highestOneBit(Math.max(1, Math.min(LARGEST_SAMPLE_BLOCK_PIXELS, largestPixels))); } - private IdentityShader identityShader(RenderType type) { + private IdentityShader identityShader(MapLayer layer) { IrisComplex complex = engine.getComplex(); ProceduralStream regionStream = complex.getRegionStream(); LabelLookup lookup = new LabelLookup(labels); - if (type == RenderType.REGION) { + if (layer.type() == RenderType.REGION) { return (double x, double z) -> { short region = lookup.idOf(regionStream.get(x, z)); return identity(region, region); }; } - ProceduralStream biomeStream = subjectBiomeStream(complex, type); + if (layer.type() == RenderType.REGION_PREVIEW) { + return regionPreviewShader(complex, layer.previewRegionKey(), lookup); + } + ProceduralStream biomeStream = subjectBiomeStream(complex, layer.type()); return (double x, double z) -> identity(lookup.idOf(biomeStream.get(x, z)), lookup.idOf(regionStream.get(x, z))); } + // Every spot is treated as part of the previewed region, so the tile shows the biomes that region places there. + private IdentityShader regionPreviewShader(IrisComplex complex, String regionKey, LabelLookup lookup) { + IrisRegion region = engine.getData().getRegionLoader().load(regionKey); + if (region == null) { + return (double x, double z) -> identity(MapLabels.NONE, MapLabels.NONE); + } + short regionId = lookup.idOf(region); + return (double x, double z) -> identity(lookup.idOf(complex.surfaceBiomeAsIfIn(region, x, z)), regionId); + } + + // Hybrid terrain decides land, coast and sea by height after the biomes are chosen, so the final biome tells. private static ProceduralStream subjectBiomeStream(IrisComplex complex, RenderType type) { return switch (type) { case BIOME_LAND -> complex.getLandBiomeStream(); case BIOME_SEA -> complex.getSeaBiomeStream(); case CAVE_LAND -> complex.getCaveBiomeStream(); - case BIOME, HEIGHT, CONTINENT, REGION -> complex.getBaseBiomeStream(); + case CONTINENT -> complex.isHybridTerrain() ? complex.getTrueBiomeStream() : complex.getBaseBiomeStream(); + case BIOME, HEIGHT, REGION, REGION_PREVIEW -> complex.getBaseBiomeStream(); }; } @@ -211,7 +227,7 @@ public final class IrisRenderer { private int subjectColor(Object subject, RenderType type) { if (type == RenderType.CONTINENT && subject instanceof IrisBiome biome) { - return continentColor(biome); + return engine.getComplex().isHybridTerrain() ? continentColorByPlacement(biome) : continentColor(biome); } if (subject instanceof IrisRegion region) { return region.getColor(engine.getComplex(), type).getRGB() & 0xFFFFFF; @@ -237,6 +253,13 @@ public final class IrisRenderer { return GREEN; } + private static int continentColorByPlacement(IrisBiome biome) { + if (biome.isShore()) { + return YELLOW; + } + return biome.isSea() ? BLUE : GREEN; + } + private static int identity(short subject, short region) { return (subject << 16) | (region & 0xFFFF); } diff --git a/core/src/main/java/com/volmit/iris/core/gui/components/MapLayer.java b/core/src/main/java/com/volmit/iris/core/gui/components/MapLayer.java new file mode 100644 index 000000000..224044620 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/components/MapLayer.java @@ -0,0 +1,43 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui.components; + +import java.util.Objects; + +/** + * What a map tile shows: one of the render types, and for a region preview which region every spot is treated as. + * + * @param previewRegionKey the region a {@link RenderType#REGION_PREVIEW} tile shows everywhere, null for every other type + */ +public record MapLayer(RenderType type, String previewRegionKey) { + public MapLayer { + Objects.requireNonNull(type, "type"); + if ((type == RenderType.REGION_PREVIEW) != (previewRegionKey != null)) { + throw new IllegalArgumentException("Only a region preview names a region"); + } + } + + public static MapLayer of(RenderType type) { + return new MapLayer(type, null); + } + + public static MapLayer regionPreview(String regionKey) { + return new MapLayer(RenderType.REGION_PREVIEW, Objects.requireNonNull(regionKey, "regionKey")); + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/components/RenderType.java b/core/src/main/java/com/volmit/iris/core/gui/components/RenderType.java index bed33acca..7de7a379b 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/components/RenderType.java +++ b/core/src/main/java/com/volmit/iris/core/gui/components/RenderType.java @@ -19,5 +19,5 @@ package com.volmit.iris.core.gui.components; public enum RenderType { - BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, HEIGHT, CONTINENT + BIOME, BIOME_LAND, BIOME_SEA, REGION, REGION_PREVIEW, CAVE_LAND, HEIGHT, CONTINENT } diff --git a/core/src/main/java/com/volmit/iris/engine/HybridBiomeSelector.java b/core/src/main/java/com/volmit/iris/engine/HybridBiomeSelector.java index 041169940..c3e0e80aa 100644 --- a/core/src/main/java/com/volmit/iris/engine/HybridBiomeSelector.java +++ b/core/src/main/java/com/volmit/iris/engine/HybridBiomeSelector.java @@ -41,20 +41,17 @@ public class HybridBiomeSelector { this.data = data; } - public IrisBiome select(List candidates, double placementHeight, double x, double z) { - IrisBiome strongest = strongestAllowedBiome(candidates, placementHeight, x, z); - return strongest != null ? strongest : closestBiomeByHeight(candidates, placementHeight); - } - - private IrisBiome strongestAllowedBiome(List candidates, double placementHeight, double x, double z) { + public Choice choose(List candidates, double placementHeight, double x, double z) { IrisBiome strongest = null; double strongestScore = Double.NEGATIVE_INFINITY; + int allowedCount = 0; for (IrisBiome biome : candidates) { if (!biome.getPlacement().allowsHeight(placementHeight)) { continue; } + allowedCount++; double score = competitionScore(biome, x, z); if (score > strongestScore) { @@ -63,7 +60,11 @@ public class HybridBiomeSelector { } } - return strongest; + if (strongest == null) { + return new Choice(closestBiomeByHeight(candidates, placementHeight), placementHeight, 0, true); + } + + return new Choice(strongest, placementHeight, allowedCount - 1, false); } private double competitionScore(IrisBiome biome, double x, double z) { @@ -91,4 +92,11 @@ public class HybridBiomeSelector { return closest; } + + /** + * @param rivalCount how many other biomes allowed this height and lost the competition + * @param fallback whether no biome allowed this height, so the one with the closest range was taken + */ + public record Choice(IrisBiome biome, double placementHeight, int rivalCount, boolean fallback) { + } } diff --git a/core/src/main/java/com/volmit/iris/engine/IrisComplex.java b/core/src/main/java/com/volmit/iris/engine/IrisComplex.java index 90f2e63f2..b11fa26e8 100644 --- a/core/src/main/java/com/volmit/iris/engine/IrisComplex.java +++ b/core/src/main/java/com/volmit/iris/engine/IrisComplex.java @@ -51,6 +51,9 @@ public class IrisComplex implements DataProvider { private static final List HYBRID_PLACED_TYPES = List.of(InferredType.LAND, InferredType.SEA, InferredType.SHORE); private RNG rng; private double fluidHeight; + private IrisDimension dimension; + private boolean hybridTerrain; + private boolean legacyRarity; private IrisData data; private Map> generators; private ProceduralStream regionStream; @@ -99,6 +102,9 @@ public class IrisComplex implements DataProvider { this.data = engine.getData(); double height = engine.getMaxHeight(); fluidHeight = engine.getDimension().getFluidHeight(); + dimension = engine.getDimension(); + hybridTerrain = dimension.getTerrainMode() == IrisTerrainMode.HYBRID; + legacyRarity = dimension.isLegacyRarity(); generators = new HashMap<>(); focusBiome = engine.getFocus(); focusRegion = engine.getFocusRegion(); @@ -135,7 +141,6 @@ public class IrisComplex implements DataProvider { : regionStyleStream .selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions()), legacy) .cache2D("regionStream", engine, cacheSize).waste("Region Stream"); - boolean hybridTerrain = engine.getDimension().getTerrainMode() == IrisTerrainMode.HYBRID; if (hybridTerrain) { prepareHybridBiomePlacement(engine, cacheSize); } @@ -150,33 +155,11 @@ public class IrisComplex implements DataProvider { .onNull(emptyBiome) ).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize).waste("Cave Biome Stream"); inferredStreams.put(InferredType.CAVE, caveBiomeStream); - landBiomeStream = hybridTerrain ? createHybridBiomeStream(engine, InferredType.LAND, cacheSize) : regionStream.contextInjecting((c, x, z) -> IrisContext.getOr(engine).getChunkContext().getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getLandBiomeStyle().create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(engine.getDimension().getLandZoom()) - .zoom(r.getLandBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getLandBiomes(), (t) -> t.setInferredType(InferredType.LAND)), legacy) - ).convertAware2D(ProceduralStream::get) - .cache2D("landBiomeStream", engine, cacheSize).waste("Land Biome Stream"); + landBiomeStream = createRegionBiomeStream(engine, InferredType.LAND, cacheSize); inferredStreams.put(InferredType.LAND, landBiomeStream); - seaBiomeStream = hybridTerrain ? createHybridBiomeStream(engine, InferredType.SEA, cacheSize) : regionStream.contextInjecting((c, x, z) -> IrisContext.getOr(engine).getChunkContext().getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getSeaBiomeStyle().create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(engine.getDimension().getSeaZoom()) - .zoom(r.getSeaBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getSeaBiomes(), (t) -> t.setInferredType(InferredType.SEA)), legacy) - ).convertAware2D(ProceduralStream::get) - .cache2D("seaBiomeStream", engine, cacheSize).waste("Sea Biome Stream"); + seaBiomeStream = createRegionBiomeStream(engine, InferredType.SEA, cacheSize); inferredStreams.put(InferredType.SEA, seaBiomeStream); - shoreBiomeStream = hybridTerrain ? createHybridBiomeStream(engine, InferredType.SHORE, cacheSize) : regionStream.contextInjecting((c, x, z) -> IrisContext.getOr(engine).getChunkContext().getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getShoreBiomeStyle().create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(r.getShoreBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getShoreBiomes(), (t) -> t.setInferredType(InferredType.SHORE)), legacy) - ).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize).waste("Shore Biome Stream"); + shoreBiomeStream = createRegionBiomeStream(engine, InferredType.SHORE, cacheSize); inferredStreams.put(InferredType.SHORE, shoreBiomeStream); bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(), Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) : @@ -203,7 +186,8 @@ public class IrisComplex implements DataProvider { .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream .convertAware2D((h, x, z) -> fixBiomeType(h, baseBiomeStream.get(x, z), - regionStream.contextInjecting((c, xx, zz) -> IrisContext.getOr(engine).getChunkContext().getRegion().get(xx, zz)).get(x, z), x, z, fluidHeight)) + regionStream.contextInjecting((c, xx, zz) -> IrisContext.getOr(engine).getChunkContext().getRegion().get(xx, zz)).get(x, z), x, z, + (type, xx, zz) -> getBiomeStream(type).get(xx, zz))) .cache2D("trueBiomeStream", engine, cacheSize).waste("True Biome Stream"); trueBiomeDerivativeStream = trueBiomeStream.contextInjecting((c, x, z) -> IrisContext.getOr(engine).getChunkContext().getBiome().get(x, z)) .convert(IrisBiome::getDerivative).cache2D("trueBiomeDerivativeStream", engine, cacheSize).waste("True Biome Derivative Stream"); @@ -274,17 +258,74 @@ public class IrisComplex implements DataProvider { } } - private ProceduralStream createHybridBiomeStream(Engine engine, InferredType type, int cacheSize) { - String name = type.name().toLowerCase() + "BiomeStream"; + private ProceduralStream createRegionBiomeStream(Engine engine, InferredType type, int cacheSize) { + String typeName = type.name().charAt(0) + type.name().substring(1).toLowerCase(Locale.ROOT); return regionStream.contextInjecting((c, x, z) -> IrisContext.getOr(engine).getChunkContext().getRegion().get(x, z)) - .convertAware2D((region, x, z) -> { - double zoom = hybridBiomeZoom(engine.getDimension(), region, type); - return hybridBiomeSelector.select(region.getBiomes(this, type), placementHeightStream.get(x, z), x / zoom, z / zoom); - }) - .cache2D(name, engine, cacheSize).waste(name); + .convertAware2D((region, x, z) -> biomeInRegion(region, type, x, z)) + .cache2D(type.name().toLowerCase(Locale.ROOT) + "BiomeStream", engine, cacheSize) + .waste(typeName + " Biome Stream"); } - private static double hybridBiomeZoom(IrisDimension dimension, IrisRegion region, InferredType type) { + /** + * The surface biome a spot would get if it belonged to the given region, for previewing one region across the map. + */ + public IrisBiome surfaceBiomeAsIfIn(IrisRegion region, double x, double z) { + if (focusBiome != null) { + return focusBiome; + } + + IrisBiome base = implode(biomeInRegion(region, bridgeStream.get(x, z), x, z), x, z); + return fixBiomeType(heightStream.get(x, z), base, region, x, z, (type, xx, zz) -> biomeInRegion(region, type, xx, zz)); + } + + /** + * Why HYBRID terrain gives a spot its base biome when the spot belongs to the given region; null for BIOME terrain. + */ + public HybridBiomeSelector.Choice explainHybridChoice(IrisRegion region, double x, double z) { + if (!hybridTerrain || focusBiome != null) { + return null; + } + + return chooseHybridBiome(region, bridgeStream.get(x, z), x, z); + } + + private IrisBiome biomeInRegion(IrisRegion region, InferredType type, double x, double z) { + if (hybridTerrain) { + return chooseHybridBiome(region, type, x, z).biome(); + } + + return noiseBiomeStream(region, type).get(x, z); + } + + private HybridBiomeSelector.Choice chooseHybridBiome(IrisRegion region, InferredType type, double x, double z) { + double zoom = hybridBiomeZoom(region, type); + return hybridBiomeSelector.choose(region.getBiomes(this, type), placementHeightStream.get(x, z), x / zoom, z / zoom); + } + + // The zooms stay chained one by one as they always were, so BIOME terrain keeps placing exactly the same biomes. + private ProceduralStream noiseBiomeStream(IrisRegion region, InferredType type) { + ProceduralStream noise = dimension.getBiomeStyle(type).create(rng.nextParallelRNG(type.ordinal()), getData()).stream() + .zoom(dimension.getBiomeZoom()); + if (type == InferredType.LAND) { + noise = noise.zoom(dimension.getLandZoom()); + } else if (type == InferredType.SEA) { + noise = noise.zoom(dimension.getSeaZoom()); + } + + return noise.zoom(region.getBiomeZoom(type)) + .selectRarity(data.getBiomeLoader().loadAll(biomeKeys(region, type), biome -> biome.setInferredType(type)), legacyRarity); + } + + private static KList biomeKeys(IrisRegion region, InferredType type) { + return switch (type) { + case LAND -> region.getLandBiomes(); + case SEA -> region.getSeaBiomes(); + case SHORE -> region.getShoreBiomes(); + default -> region.getCaveBiomes(); + }; + } + + private double hybridBiomeZoom(IrisRegion region, InferredType type) { double typeZoom = switch (type) { case LAND -> dimension.getLandZoom(); case SEA -> dimension.getSeaZoom(); @@ -334,23 +375,23 @@ public class IrisComplex implements DataProvider { return null; } - private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) { + private IrisBiome fixBiomeType(double height, IrisBiome biome, IrisRegion region, double x, double z, BiomeOfType biomeOfType) { double sh = region.getShoreHeight(x, z); if (height >= fluidHeight - 1 && height <= fluidHeight + sh && !biome.isShore()) { - return shoreBiomeStream.get(x, z); + return biomeOfType.at(InferredType.SHORE, x, z); } if (height > fluidHeight + sh && !biome.isLand()) { - return landBiomeStream.get(x, z); + return biomeOfType.at(InferredType.LAND, x, z); } if (height < fluidHeight && !biome.isAquatic()) { - return seaBiomeStream.get(x, z); + return biomeOfType.at(InferredType.SEA, x, z); } if (height == fluidHeight && !biome.isShore()) { - return shoreBiomeStream.get(x, z); + return biomeOfType.at(InferredType.SHORE, x, z); } return biome; @@ -464,4 +505,9 @@ public class IrisComplex implements DataProvider { public void close() { } + + @FunctionalInterface + private interface BiomeOfType { + IrisBiome at(InferredType type, double x, double z); + } } diff --git a/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java b/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java index 85f4b4566..19254e2ee 100644 --- a/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java @@ -666,7 +666,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { public Color getColor(Engine engine, RenderType type) { switch (type) { - case BIOME, HEIGHT, CAVE_LAND, REGION, BIOME_SEA, BIOME_LAND -> { + case BIOME, HEIGHT, CAVE_LAND, REGION, REGION_PREVIEW, BIOME_SEA, BIOME_LAND -> { return this.cacheColor.aquire(() -> { if (this.color == null) { RandomColor randomColor = new RandomColor(getName().hashCode());