mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 08:07:42 +00:00
Explore region biomes by height
Preview, height chart; hybrid coastlines.
This commit is contained in:
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());
|
||||
|
||||
Reference in new issue
Block a user