Explore region biomes by height

Preview, height chart; hybrid coastlines.
This commit is contained in:
piexel committed 2026-10-21 18:38:26 +02:00
1 parent ea42141550
commit 29942fa1c2
18 files changed
+1491 -90

No files matched your search

@@ -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<GuiMarker> players = List.of();
private List<GuiMarker> 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<MapCensus.Share> 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<MapCensus.Share> shares, MapStyle.Highlight highlight);
@@ -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<String> lines, MapLabels.Label label) {
@@ -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
) {
}
}
@@ -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),
@@ -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<JComponent> mapControls = new ArrayList<>();
private final List<JComponent> explorerControls = new ArrayList<>();
private final List<JComponent> 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<MapView, ObjectExplorer> 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<MapCensus.Share> 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();
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<InferredType> PLACED_TYPES = List.of(InferredType.LAND, InferredType.SHORE, InferredType.SEA);
private final Supplier<Engine> engineSource;
private final Consumer<String> 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<Engine> engineSource, Consumer<String> 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<RegionRow> 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<BiomeRange> biomeRanges(Engine engine, IrisRegion region) {
List<BiomeRange> 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) {
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<InferredType> 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<BiomeRange> 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<BiomeRange> 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;
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<InferredType> MEASURED_TYPES = List.of(InferredType.SEA, InferredType.LAND);
private static final int MAXIMUM_NOTES = 8;
private final List<BiomeRange> ranges;
private final int lowestHeight;
private final int highestHeight;
private final int[] countsFromLowest;
private final int highestCount;
private RegionHeightCoverage(List<BiomeRange> 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<BiomeRange> 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<BiomeRange> 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<HeightBand> 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<HeightBand> bands = new ArrayList<>();
if (total == 0) {
return bands;
}
List<BiomeRange> typeRanges = ranges(type);
int bandStart = from;
List<BiomeRange> bandBiomes = coveringBiomes(typeRanges, from);
for (int height = from + 1; height <= to + 1; height++) {
List<BiomeRange> 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<String> notes() {
List<String> 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<String> notes) {
List<BiomeRange> 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<BiomeRange> coveringBiomes(List<BiomeRange> 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<BiomeRange> biomes) {
List<String> 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<BiomeRange> biomes, double share) {
boolean isGap() {
return biomes.isEmpty();
}
boolean isShared() {
return biomes.size() > 1;
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> 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;
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> onRegionChosen;
private final JTextField searchField = new JTextField();
private final DefaultListModel<RegionRow> rows = new DefaultListModel<>();
private final JList<RegionRow> list = new JList<>(rows);
private List<RegionRow> regions = List.of();
private String chosenKey;
private boolean updatingRows;
/**
* @param onRegionChosen receives the chosen region's key, or null for all regions
*/
RegionNavigator(Consumer<String> 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<RegionRow> 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();
}
}
}
}
@@ -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<String> openInEditor(double worldX, double worldZ, RenderType type) {
Optional<String> 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;
@@ -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<FallbackPiece> 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);
}
}
@@ -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<IrisRegion> 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<IrisBiome> biomeStream = subjectBiomeStream(complex, type);
if (layer.type() == RenderType.REGION_PREVIEW) {
return regionPreviewShader(complex, layer.previewRegionKey(), lookup);
}
ProceduralStream<IrisBiome> 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<IrisBiome> subjectBiomeStream(IrisComplex complex, RenderType type) {
return switch (type) {
case BIOME_LAND -> complex.getLandBiomeStream();
case BIOME_SEA -> complex.getSeaBiomeStream();
case CAVE_LAND -> complex.getCaveBiomeStream();
case BIOME, 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);
}
@@ -0,0 +1,43 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui.components;
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"));
}
}
@@ -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
}
@@ -41,20 +41,17 @@ public class HybridBiomeSelector {
this.data = data;
}
public IrisBiome select(List<IrisBiome> 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<IrisBiome> candidates, double placementHeight, double x, double z) {
public Choice choose(List<IrisBiome> 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) {
}
}
@@ -51,6 +51,9 @@ public class IrisComplex implements DataProvider {
private static final List<InferredType> 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<IrisInterpolator, Set<IrisGenerator>> generators;
private ProceduralStream<IrisRegion> 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<IrisBiome> createHybridBiomeStream(Engine engine, InferredType type, int cacheSize) {
String name = type.name().toLowerCase() + "BiomeStream";
private ProceduralStream<IrisBiome> 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<IrisBiome> noiseBiomeStream(IrisRegion region, InferredType type) {
ProceduralStream<Double> 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<String> 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);
}
}
@@ -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());