Show the pick path in the hover card

Biome title, pick tree with noise bands.
This commit is contained in:
piexel committed 2026-10-21 21:32:03 +02:00
1 parent 97612385e5
commit 86ef09e768
7 files changed
+522 -225

No files matched your search

@@ -1103,7 +1103,7 @@ final class MapCanvas extends JPanel {
double worldZ = screenToWorldZ(point.y);
controller.submitProbe(
frame,
() -> MapHoverProbe.sample(probeEngine, worldX, worldZ, previewedRegion(), currentType.drawsPickNoise()),
() -> MapHoverProbe.sample(probeEngine, worldX, worldZ, previewedRegion(), hoverExplainsBiomePick()),
details -> {
QuickHover quick = quickHover;
if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) {
@@ -1114,6 +1114,11 @@ final class MapCanvas extends JPanel {
);
}
// These views' hover card names the surface biome, so the whole way to it is worth looking up.
private boolean hoverExplainsBiomePick() {
return currentType.drawsPickNoise() || currentType == RenderType.HEIGHT;
}
private String previewedRegion() {
return currentType == RenderType.REGION_PREVIEW ? previewRegionKey : null;
}
@@ -18,258 +18,466 @@
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.MapHoverProbe.PickStep;
import com.volmit.iris.core.gui.NoisePickSpread.Band;
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.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.RoundRectangle2D;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* The card that follows the cursor: what the drawn map shows there at once, and the rest once probed, in titled
* sections of labelled values.
* The card that follows the cursor, laid out like IntelliJ's documentation popup: the biome as the title, the steps
* that picked it as a small tree, and with Shift the terrain, location and pack files below.
*/
record MapHoverCard(String title, String subtitle, List<Section> sections, String footer) {
record MapHoverCard(Header header, String subtitle, String pathTitle, List<PickStep> path, boolean showsBands,
List<Group> groups, List<Hint> hints) {
private static final int MINIMUM_CONTENT_WIDTH = 260;
private static final int PADDING = 12;
private static final int SUBTITLE_GAP = 2;
private static final int SECTION_GAP = 10;
private static final int SECTION_TITLE_GAP = 4;
private static final int HEADER_TOP = 10;
private static final int HEADER_ICON_SIZE = 14;
private static final int NODE_ICON_SIZE = 12;
private static final int ICON_GAP = 8;
private static final int KIND_GAP = 16;
private static final int GROUP_GAP = 8;
private static final int GROUP_TITLE_GAP = 4;
private static final int NODE_HEIGHT = 24;
private static final int NODE_INDENT = 18;
private static final int CHIP_GAP = 16;
private static final int CHIP_PADDING_X = 5;
private static final int CHIP_PADDING_Y = 1;
private static final int BAND_HEIGHT = 4;
private static final int BAND_GAP = 6;
private static final int MARKER_WIDTH = 2;
private static final int MARKER_HEIGHT = 10;
private static final int COLUMN_GAP = 14;
private static final int ROW_GAP = 2;
private static final int ROW_GAP = 3;
private static final int BOTTOM_PADDING = 10;
private static final int FOOTER_PADDING_Y = 7;
private static final int KEY_PADDING_X = 4;
private static final int KEY_GAP = 4;
private static final int HINT_GAP = 14;
private static final Color ICON_EDGE = new ThemeColor(new Color(0, 0, 0, 38), new Color(255, 255, 255, 38));
private static final Color MARKER_EDGE = new Color(0, 0, 0, 200);
/**
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
* @param region the region the drawn tile shows
* @param details the probed facts about the spot, or null until they arrive
* @param detailed whether Shift is held, which adds the pack files
* @param detailed whether Shift is held, which trades the noise bands for terrain, location and pack files
*/
static MapHoverCard of(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
RenderType type, boolean detailed, double worldX, double worldZ) {
List<Section> sections = new ArrayList<>();
if (details != null) {
sections.add(terrainSection(details, type));
addPickSection(sections, details);
}
sections.add(locationSection(worldX, worldZ, detailed));
List<PickStep> path = details == null ? List.of() : details.pickPath();
IrisBiome biome = cardBiome(subject, details, type);
List<Group> groups = new ArrayList<>();
if (detailed) {
addFilesSection(sections, subject, region);
addTerrainGroup(groups, details, type);
groups.add(locationGroup(worldX, worldZ));
addFilesGroup(groups, biome, details != null ? details.region() : region == null ? null : region.subject());
}
return new MapHoverCard(title(subject, details), subtitle(region, details, type), sections,
detailed ? null : "Shift for pack files · H highlights");
return new MapHoverCard(header(subject, biome, type, path), subtitle(details), pathTitle(path), path, !detailed,
groups, hints(type, detailed));
}
/**
* Draws the card with its top left corner as close to the given point as fits inside the bounds.
*/
void paint(Graphics2D canvas, int anchorX, int anchorY, int boundsWidth, int boundsHeight) {
Graphics2D card = (Graphics2D) canvas.create();
card.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
card.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int padding = StudioTheme.scale(PADDING);
int labelWidth = labelColumnWidth(canvas);
int width = Math.max(headerWidth(canvas), labelWidth + StudioTheme.scale(COLUMN_GAP) + valueColumnWidth(canvas)) + padding * 2;
int height = contentHeight(canvas) + padding * 2;
int contentWidth = contentWidth(card);
int width = contentWidth + padding * 2;
int footerHeight = footerHeight(card);
int height = StudioTheme.scale(HEADER_TOP) + headerHeight(card) + pathHeight(card) + groupsHeight(card)
+ StudioTheme.scale(BOTTOM_PADDING) + footerHeight;
int x = Math.max(padding, Math.min(boundsWidth - width - padding, anchorX));
int y = Math.max(padding, Math.min(boundsHeight - height - padding, anchorY));
StudioOverlay.drawCard(canvas, x, y, width, height);
StudioOverlay.drawCard(card, x, y, width, height, footerHeight);
int left = x + padding;
int top = y + padding;
top = drawLine(canvas, title, StudioTheme.boldFont(), StudioTheme.TEXT, left, top);
if (subtitle != null) {
top = drawLine(canvas, subtitle, StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, left,
top + StudioTheme.scale(SUBTITLE_GAP));
}
for (Section section : sections) {
top += StudioTheme.scale(SECTION_GAP);
canvas.setColor(StudioTheme.OVERLAY_BORDER);
canvas.fillRect(left, top, width - padding * 2, 1);
top += StudioTheme.scale(SECTION_GAP) / 2;
top = drawLine(canvas, section.title().toUpperCase(Locale.ROOT), StudioTheme.sectionFont(), StudioTheme.MUTED_TEXT,
left, top) + StudioTheme.scale(SECTION_TITLE_GAP);
for (Row row : section.rows()) {
drawLine(canvas, row.label(), StudioTheme.font(), StudioTheme.SECONDARY_TEXT, left, top);
top = drawLine(canvas, row.value(), StudioTheme.font(), StudioTheme.TEXT,
left + labelWidth + StudioTheme.scale(COLUMN_GAP), top) + StudioTheme.scale(ROW_GAP);
}
}
if (footer != null) {
drawLine(canvas, footer, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, top + StudioTheme.scale(SECTION_GAP));
}
int top = y + StudioTheme.scale(HEADER_TOP);
top = drawHeader(card, left, top, contentWidth);
top = drawPath(card, left, top, contentWidth);
drawGroups(card, left, top);
drawFooter(card, left, y + height - footerHeight);
card.dispose();
}
private int labelColumnWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
return sections.stream().flatMap(section -> section.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0);
private int contentWidth(Graphics2D canvas) {
int width = Math.max(headerWidth(canvas), pathWidth(canvas));
width = Math.max(width, groupsWidth(canvas));
width = Math.max(width, footerWidth(canvas));
return Math.max(StudioTheme.scale(MINIMUM_CONTENT_WIDTH), width);
}
private int valueColumnWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
return sections.stream().flatMap(section -> section.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.value())).max().orElse(0);
// Header: the biome's colour, its name in bold with its kind on the right, and the ground's height below.
private int headerTextOffset() {
return header.color() == null ? 0 : StudioTheme.scale(HEADER_ICON_SIZE + ICON_GAP);
}
private int headerWidth(Graphics2D canvas) {
int width = canvas.getFontMetrics(StudioTheme.boldFont()).stringWidth(title);
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
if (subtitle != null) {
width = Math.max(width, small.stringWidth(subtitle));
int width = headerTextOffset() + canvas.getFontMetrics(StudioTheme.boldFont()).stringWidth(header.name());
if (header.kind() != null) {
width += StudioTheme.scale(KIND_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(header.kind());
}
if (footer != null) {
width = Math.max(width, small.stringWidth(footer));
if (subtitle != null) {
width = Math.max(width, headerTextOffset() + canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(subtitle));
}
return width;
}
private int contentHeight(Graphics2D canvas) {
int small = canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
int row = canvas.getFontMetrics(StudioTheme.font()).getHeight() + StudioTheme.scale(ROW_GAP);
int sectionTitle = canvas.getFontMetrics(StudioTheme.sectionFont()).getHeight() + StudioTheme.scale(SECTION_TITLE_GAP);
private int headerHeight(Graphics2D canvas) {
int height = canvas.getFontMetrics(StudioTheme.boldFont()).getHeight();
if (subtitle != null) {
height += StudioTheme.scale(SUBTITLE_GAP) + small;
}
for (Section section : sections) {
height += StudioTheme.scale(SECTION_GAP) + StudioTheme.scale(SECTION_GAP) / 2 + sectionTitle + section.rows().size() * row;
}
if (footer != null) {
height += StudioTheme.scale(SECTION_GAP) + small;
height += canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
}
return height;
}
private static int drawLine(Graphics2D canvas, String text, Font font, Color color, int left, int top) {
canvas.setFont(font);
canvas.setColor(color);
FontMetrics metrics = canvas.getFontMetrics();
canvas.drawString(text, left, top + metrics.getAscent());
return top + metrics.getHeight();
private int drawHeader(Graphics2D canvas, int left, int top, int contentWidth) {
FontMetrics bold = canvas.getFontMetrics(StudioTheme.boldFont());
if (header.color() != null) {
int iconSize = StudioTheme.scale(HEADER_ICON_SIZE);
drawIcon(canvas, header.color(), left, top + (bold.getHeight() - iconSize) / 2, iconSize);
}
int textLeft = left + headerTextOffset();
int baseline = top + bold.getAscent();
drawText(canvas, header.name(), StudioTheme.boldFont(), StudioTheme.TEXT, textLeft, baseline);
if (header.kind() != null) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
drawText(canvas, header.kind(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT,
left + contentWidth - small.stringWidth(header.kind()), baseline);
}
top += bold.getHeight();
if (subtitle != null) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
drawText(canvas, subtitle, StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, textLeft, top + small.getAscent());
top += small.getHeight();
}
return top;
}
private static String title(MapLabels.Label subject, MapHoverProbe.Details details) {
if (subject != null) {
return subject.name();
// Path: one indented row per step, its value as a chip on the right and, without Shift, the noise bands below it.
private int pathWidth(Graphics2D canvas) {
int width = 0;
for (int level = 0; level < path.size(); level++) {
PickStep step = path.get(level);
int row = nodeTextLeft(level) + canvas.getFontMetrics(StudioTheme.font()).stringWidth(step.picked().name() + " ")
+ canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(step.reason());
if (step.value() != null) {
row += StudioTheme.scale(CHIP_GAP) + chipWidth(canvas, step.value());
}
width = Math.max(width, row);
}
if (details != null && details.surfaceBiome() != null) {
return details.surfaceBiome().getName();
}
return "Drawing…";
return width;
}
// The region view's title already is the region, so only the kind of ground is left to say.
private static String subtitle(MapLabels.Label region, MapHoverProbe.Details details, RenderType type) {
List<String> parts = new ArrayList<>();
if (type != RenderType.REGION) {
String regionName = region != null ? region.name() : details != null ? details.region().getName() : null;
if (regionName != null) {
parts.add(regionName + " region");
private int pathHeight(Graphics2D canvas) {
if (path.isEmpty()) {
return 0;
}
int height = groupTitleHeight(canvas);
for (PickStep step : path) {
height += nodeHeight(step);
}
return height;
}
private int nodeHeight(PickStep step) {
int height = StudioTheme.scale(NODE_HEIGHT);
return showsBands && step.showsBands() ? height + StudioTheme.scale(BAND_HEIGHT + BAND_GAP) : height;
}
private static int nodeTextLeft(int level) {
return StudioTheme.scale(level * NODE_INDENT + NODE_ICON_SIZE + ICON_GAP);
}
private int drawPath(Graphics2D canvas, int left, int top, int contentWidth) {
if (path.isEmpty()) {
return top;
}
top = drawGroupTitle(canvas, pathTitle, left, top);
for (int level = 0; level < path.size(); level++) {
PickStep step = path.get(level);
drawNode(canvas, step, left, top, level, contentWidth);
if (showsBands && step.showsBands()) {
int bandsLeft = left + nodeTextLeft(level);
drawBands(canvas, step, bandsLeft, top + StudioTheme.scale(NODE_HEIGHT), left + contentWidth - bandsLeft);
}
top += nodeHeight(step);
}
return top;
}
private void drawNode(Graphics2D canvas, PickStep step, int left, int top, int level, int contentWidth) {
int rowHeight = StudioTheme.scale(NODE_HEIGHT);
int iconSize = StudioTheme.scale(NODE_ICON_SIZE);
drawIcon(canvas, step.picked().color(), left + StudioTheme.scale(level * NODE_INDENT), top + (rowHeight - iconSize) / 2,
iconSize);
FontMetrics regular = canvas.getFontMetrics(StudioTheme.font());
int baseline = top + (rowHeight - regular.getHeight()) / 2 + regular.getAscent();
int textLeft = left + nodeTextLeft(level);
String name = step.picked().name() + " ";
drawText(canvas, name, StudioTheme.font(), StudioTheme.TEXT, textLeft, baseline);
drawText(canvas, step.reason(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, textLeft + regular.stringWidth(name),
baseline);
if (step.value() != null) {
drawChip(canvas, step.value(), left + contentWidth - chipWidth(canvas, step.value()), top, rowHeight);
}
}
// A strip with each option's band of noise in its colour, and a marker where this spot's noise falls.
private static void drawBands(Graphics2D canvas, PickStep step, int left, int top, int width) {
int height = StudioTheme.scale(BAND_HEIGHT);
Shape clip = canvas.getClip();
canvas.clip(new RoundRectangle2D.Double(left, top, width, height, height, height));
for (Band band : step.bands()) {
int from = left + (int) Math.round(band.fromNoise() * width);
int to = left + (int) Math.round(band.toNoise() * width);
canvas.setColor(new Color(band.option().color()));
canvas.fillRect(from, top, Math.max(1, to - from), height);
}
canvas.setClip(clip);
int markerWidth = StudioTheme.scale(MARKER_WIDTH);
int markerHeight = StudioTheme.scale(MARKER_HEIGHT);
int markerX = left + (int) Math.round(Math.clamp(step.noise(), 0D, 1D) * (width - markerWidth));
int markerY = top + (height - markerHeight) / 2;
canvas.setColor(MARKER_EDGE);
canvas.fillRect(markerX - 1, markerY - 1, markerWidth + 2, markerHeight + 2);
canvas.setColor(Color.WHITE);
canvas.fillRect(markerX, markerY, markerWidth, markerHeight);
}
private static Font chipFont() {
return StudioTheme.monospacedFont().deriveFont(StudioTheme.smallFont().getSize2D());
}
private static int chipWidth(Graphics2D canvas, String value) {
return canvas.getFontMetrics(chipFont()).stringWidth(value) + StudioTheme.scale(CHIP_PADDING_X) * 2;
}
// Styled like an inlay hint in IntelliJ's editor.
private static void drawChip(Graphics2D canvas, String value, int left, int rowTop, int rowHeight) {
FontMetrics metrics = canvas.getFontMetrics(chipFont());
int height = metrics.getHeight() + StudioTheme.scale(CHIP_PADDING_Y) * 2;
int top = rowTop + (rowHeight - height) / 2;
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
int arc = StudioTheme.scale(8);
canvas.fillRoundRect(left, top, chipWidth(canvas, value), height, arc, arc);
drawText(canvas, value, chipFont(), StudioTheme.SECONDARY_TEXT, left + StudioTheme.scale(CHIP_PADDING_X),
top + StudioTheme.scale(CHIP_PADDING_Y) + metrics.getAscent());
}
private static void drawIcon(Graphics2D canvas, int rgb, int left, int top, int size) {
int arc = Math.max(2, size / 2);
canvas.setColor(new Color(rgb));
canvas.fillRoundRect(left, top, size, size, arc, arc);
canvas.setColor(ICON_EDGE);
canvas.drawRoundRect(left, top, size - 1, size - 1, arc, arc);
}
// Groups: sentence-case titles over rows of muted labels and values, all labels in one column.
private int labelColumnWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
return groups.stream().flatMap(group -> group.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0);
}
private int groupsWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
int valueWidth = groups.stream().flatMap(group -> group.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.value())).max().orElse(0);
return labelColumnWidth(canvas) + StudioTheme.scale(COLUMN_GAP) + valueWidth;
}
private int groupsHeight(Graphics2D canvas) {
int rowHeight = canvas.getFontMetrics(StudioTheme.font()).getHeight() + StudioTheme.scale(ROW_GAP);
return groups.stream().mapToInt(group -> groupTitleHeight(canvas) + group.rows().size() * rowHeight).sum();
}
private void drawGroups(Graphics2D canvas, int left, int top) {
FontMetrics regular = canvas.getFontMetrics(StudioTheme.font());
int valueLeft = left + labelColumnWidth(canvas) + StudioTheme.scale(COLUMN_GAP);
for (Group group : groups) {
top = drawGroupTitle(canvas, group.title(), left, top);
for (Row row : group.rows()) {
int baseline = top + regular.getAscent();
drawText(canvas, row.label(), StudioTheme.font(), StudioTheme.SECONDARY_TEXT, left, baseline);
drawText(canvas, row.value(), StudioTheme.font(), row.link() ? StudioTheme.LINK : StudioTheme.TEXT, valueLeft,
baseline);
top += regular.getHeight() + StudioTheme.scale(ROW_GAP);
}
}
if (details != null && details.terrainKind() != null) {
parts.add(details.terrainKind());
}
return parts.isEmpty() ? null : String.join(" · ", parts);
}
private static Section terrainSection(MapHoverProbe.Details details, RenderType type) {
private static int groupTitleHeight(Graphics2D canvas) {
return StudioTheme.scale(GROUP_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight()
+ StudioTheme.scale(GROUP_TITLE_GAP);
}
private static int drawGroupTitle(Graphics2D canvas, String title, int left, int top) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
top += StudioTheme.scale(GROUP_GAP);
drawText(canvas, title, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, top + small.getAscent());
return top + small.getHeight() + StudioTheme.scale(GROUP_TITLE_GAP);
}
// Footer: the shortcuts as key caps, each followed by what it does.
private int footerWidth(Graphics2D canvas) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
int width = 0;
for (Hint hint : hints) {
width += keyWidth(small, hint.key()) + StudioTheme.scale(KEY_GAP) + small.stringWidth(hint.action());
}
return width + StudioTheme.scale(HINT_GAP) * Math.max(0, hints.size() - 1);
}
private static int footerHeight(Graphics2D canvas) {
return canvas.getFontMetrics(StudioTheme.smallFont()).getHeight() + StudioTheme.scale(FOOTER_PADDING_Y) * 2;
}
private static int keyWidth(FontMetrics small, String key) {
return small.stringWidth(key) + StudioTheme.scale(KEY_PADDING_X) * 2;
}
private void drawFooter(Graphics2D canvas, int left, int top) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
int textTop = top + StudioTheme.scale(FOOTER_PADDING_Y);
int baseline = textTop + small.getAscent();
int arc = StudioTheme.scale(6);
for (Hint hint : hints) {
int keyWidth = keyWidth(small, hint.key());
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(left, textTop, keyWidth, small.getHeight(), arc, arc);
drawText(canvas, hint.key(), StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT,
left + StudioTheme.scale(KEY_PADDING_X), baseline);
left += keyWidth + StudioTheme.scale(KEY_GAP);
drawText(canvas, hint.action(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, baseline);
left += small.stringWidth(hint.action()) + StudioTheme.scale(HINT_GAP);
}
}
private static void drawText(Graphics2D canvas, String text, Font font, Color color, int left, int baseline) {
canvas.setFont(font);
canvas.setColor(color);
canvas.drawString(text, left, baseline);
}
// The card is about a biome; the region view's tiles only know the region until the probe finds the biome.
private static IrisBiome cardBiome(MapLabels.Label subject, MapHoverProbe.Details details, RenderType type) {
if (subject != null && subject.subject() instanceof IrisBiome biome) {
return biome;
}
if (details == null) {
return null;
}
return type == RenderType.CAVE_LAND ? details.caveBiome() : details.surfaceBiome();
}
private static Header header(MapLabels.Label subject, IrisBiome biome, RenderType type, List<PickStep> path) {
if (biome != null) {
return new Header(biome.getName(), colorOf(biome, path), type == RenderType.CAVE_LAND ? "Cave biome" : "Biome");
}
if (subject != null) {
return new Header(subject.name(), null, subject.kind() == MapLabels.Kind.REGION ? "Region" : "Biome");
}
return new Header("Drawing…", null, null);
}
private static Integer colorOf(IrisBiome biome, List<PickStep> path) {
return path.stream()
.filter(step -> step.picked().key().equals(biome.getLoadKey()))
.reduce((first, last) -> last)
.map(step -> step.picked().color())
.orElse(null);
}
private static String subtitle(MapHoverProbe.Details details) {
if (details == null) {
return null;
}
return Math.round(details.height()) + " · " + heightAboveSea(details.height() - details.fluidHeight()) + " · slope "
+ Math.round(details.slopeDegrees()) + "°";
}
private static String pathTitle(List<PickStep> path) {
return path.size() > 1 ? "Picked by" : "Region";
}
private static void addTerrainGroup(List<Group> groups, MapHoverProbe.Details details, RenderType type) {
if (details == null) {
return;
}
List<Row> rows = new ArrayList<>();
long height = Math.round(details.height());
rows.add(new Row("Height", height + " · " + heightAboveSea(details.height() - details.fluidHeight())));
rows.add(new Row("Slope", Math.round(details.slopeDegrees()) + "°"));
IrisBiome surface = details.surfaceBiome();
IrisBiome cave = details.caveBiome();
if (type == RenderType.CAVE_LAND && surface != null) {
rows.add(new Row("Surface biome", surface.getName()));
rows.add(new Row("Surface biome", surface.getName(), false));
} else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) {
rows.add(new Row("Cave biome", cave.getName()));
rows.add(new Row("Cave biome", cave.getName(), false));
}
if (details.vanillaBiome() != null) {
rows.add(new Row("Minecraft", details.vanillaBiome().replace('_', ' ')));
}
return new Section("Terrain", rows);
}
private static void addPickSection(List<Section> sections, MapHoverProbe.Details details) {
List<Row> rows = new ArrayList<>();
MapHoverProbe.PickNoise noise = details.pickNoise();
if (noise != null && noise.regionNoise() != null) {
rows.add(new Row("Region noise", noiseText(noise.regionNoise())));
}
if (noise != null && noise.biomeType() != null) {
String label = capitalize(noise.biomeType().name().toLowerCase(Locale.ROOT)) + " noise";
rows.add(new Row(label, biomeNoiseText(noise, details.surfaceBiome())));
}
if (details.placement() != null) {
rows.add(new Row("Height", placementText(details.placement())));
rows.add(new Row("Minecraft", details.vanillaBiome().replace('_', ' '), false));
}
if (!rows.isEmpty()) {
sections.add(new Section("Picked by", rows));
groups.add(new Group("Terrain", rows));
}
}
private static String biomeNoiseText(MapHoverProbe.PickNoise noise, IrisBiome surface) {
String value = noiseText(noise.biomeNoise());
IrisBiome picked = noise.noisePick();
if (picked == null) {
return value;
}
if (surface == null || picked.getLoadKey().equals(surface.getLoadKey())) {
return value + " → " + picked.getName();
}
return value + " → " + picked.getName() + ", child " + surface.getName() + " took over";
}
// 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 = heightAboveSea(choice.placementHeight()) + " · ";
if (choice.fallback()) {
return height + "no range covers it, closest wins";
}
if (choice.rivalCount() == 0) {
return height + "only biome in range";
}
return height + "beat " + choice.rivalCount() + (choice.rivalCount() == 1 ? " other" : " others") + " in range";
}
private static Section locationSection(double worldX, double worldZ, boolean detailed) {
private static Group locationGroup(double worldX, double worldZ) {
int blockX = MapCanvas.floorWorldCoordinate(worldX);
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
List<Row> rows = new ArrayList<>();
rows.add(new Row("Block", blockX + ", " + blockZ));
rows.add(new Row("Chunk", (blockX >> 4) + ", " + (blockZ >> 4)));
if (detailed) {
rows.add(new Row("Region file", "r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca"));
}
return new Section("Location", rows);
return new Group("Location", List.of(
new Row("Block", blockX + ", " + blockZ, false),
new Row("Chunk", (blockX >> 4) + ", " + (blockZ >> 4), false),
new Row("Region file", "r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca", false)));
}
private static void addFilesSection(List<Section> sections, MapLabels.Label subject, MapLabels.Label region) {
private static void addFilesGroup(List<Group> groups, IrisBiome biome, Object region) {
List<Row> rows = new ArrayList<>();
addPackFile(rows, subject);
if (subject != null && subject.kind() != MapLabels.Kind.REGION) {
addPackFile(rows, region);
if (biome != null) {
rows.add(new Row("Biome", packPath(biome), true));
}
if (region instanceof IrisRegistrant registrant) {
rows.add(new Row("Region", packPath(registrant), true));
}
if (!rows.isEmpty()) {
sections.add(new Section("Pack files", rows));
groups.add(new Group("Files", rows));
}
}
// The key is the path under the type's folder, so folder, key and extension give the file within the pack.
private static void addPackFile(List<Row> rows, MapLabels.Label label) {
if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) {
return;
}
String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome";
private static String packPath(IrisRegistrant registrant) {
File file = registrant.getLoadFile();
String fileName = file == null ? "" : file.getName();
int extensionStart = fileName.lastIndexOf('.');
String extension = extensionStart < 0 ? "" : fileName.substring(extensionStart);
rows.add(new Row(kind, registrant.getFolderName() + "/" + label.key() + extension));
return registrant.getFolderName() + "/" + registrant.getLoadKey() + extension;
}
private static List<Hint> hints(RenderType type, boolean detailed) {
List<Hint> hints = new ArrayList<>();
if (!detailed) {
hints.add(new Hint("Shift", "More"));
}
hints.add(new Hint("H", "Highlight"));
if (type != RenderType.CONTINENT) {
hints.add(new Hint("Alt+Click", "Open file"));
}
return hints;
}
private static String heightAboveSea(double height) {
@@ -277,17 +485,19 @@ record MapHoverCard(String title, String subtitle, List<Section> sections, Strin
return rounded < 0 ? -rounded + " below sea" : rounded + " above sea";
}
private static String noiseText(double noise) {
return String.format(Locale.ROOT, "%.3f", noise);
/**
* @param color the biome's map colour, or null when no step of the path tells it
* @param kind what the title names, like "Biome", or null while drawing
*/
record Header(String name, Integer color, String kind) {
}
private static String capitalize(String text) {
return Character.toUpperCase(text.charAt(0)) + text.substring(1);
record Group(String title, List<Row> rows) {
}
record Section(String title, List<Row> rows) {
record Row(String label, String value, boolean link) {
}
record Row(String label, String value) {
record Hint(String key, String action) {
}
}
@@ -18,6 +18,8 @@
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.NoisePickSpread.Band;
import com.volmit.iris.core.gui.NoisePickSpread.Option;
import com.volmit.iris.engine.HybridBiomeSelector;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
@@ -27,6 +29,8 @@ import com.volmit.iris.engine.object.IrisDimension;
import com.volmit.iris.engine.object.IrisRegion;
import org.bukkit.block.Biome;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
@@ -41,61 +45,108 @@ final class MapHoverProbe {
/**
* @param previewRegionKey the region the region preview treats every spot as part of, or null for the real world
* @param explainPicks also look up the noise values that picked the region and biome, for the region view
* @param explainBiomePick whether the card shows the surface biome, so the whole way to it is worth looking up;
* otherwise only how the region was picked
*/
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey, boolean explainPicks) {
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey, boolean explainBiomePick) {
IrisComplex complex = engine.getComplex();
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);
InferredType terrainKind = complex.getBridgeStream().get(worldX, worldZ);
IrisComplex.BiomePick surfacePick = complex.surfacePickAsIfIn(region, worldX, worldZ);
Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ);
double height = complex.getHeightStream().getDouble(worldX, worldZ);
double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
IrisDimension dimension = engine.getDimension();
return new Details(
worldX,
worldZ,
surfaceBiome,
caveBiome,
surfacePick.biome(),
complex.getCaveBiomeStream().get(worldX, worldZ),
region,
terrainKindName(surfaceBiome, terrainKind),
vanillaBiome == null ? null : vanillaBiome.getKey().getKey(),
height,
complex.getHeightStream().getDouble(worldX, worldZ),
dimension == null ? 0D : dimension.getFluidHeight(),
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)),
complex.explainHybridChoice(region, worldX, worldZ),
explainPicks ? pickNoise(complex, region, previewRegion, worldX, worldZ) : null
pickPath(engine, region, previewRegion != null, explainBiomePick ? surfacePick : null, worldX, worldZ)
);
}
// HYBRID terrain picks biomes by height, which the placement line already tells, so only the region noise is left.
// The preview shows surface biomes, which the height can move to another biome list; the real map shows base biomes.
private static PickNoise pickNoise(IrisComplex complex, IrisRegion region, IrisRegion previewRegion, double x, double z) {
Double regionNoise = previewRegion == null ? complex.getRegionStyleStream().get(x, z) : null;
if (complex.isHybridTerrain() || region == null) {
return new PickNoise(regionNoise, null, Double.NaN, null);
// The region preview chose the region itself; the real map picks it by the region noise.
private static List<PickStep> pickPath(Engine engine, IrisRegion region, boolean previewed,
IrisComplex.BiomePick surfacePick, double x, double z) {
List<PickStep> path = new ArrayList<>();
path.add(previewed ? previewedRegionStep(engine, region) : regionStep(engine, region, x, z));
if (surfacePick == null || surfacePick.type() == null || engine.getComplex().getFocusBiome() != null) {
return path;
}
InferredType biomeType = previewRegion != null
? complex.surfacePickAsIfIn(region, x, z).type()
: complex.getBridgeStream().get(x, z);
return new PickNoise(regionNoise, biomeType, complex.biomePickNoise(region, biomeType, x, z),
complex.biomePickedByNoise(region, biomeType, x, z));
PickStep biomeStep = engine.getComplex().isHybridTerrain()
? heightStep(engine, region, surfacePick.type(), x, z)
: noiseStep(engine, region, surfacePick.type(), x, z);
if (biomeStep == null) {
return path;
}
path.add(biomeStep);
if (!biomeStep.picked().key().equals(surfacePick.biome().getLoadKey())) {
path.add(new PickStep(PickMeasuring.biomeOption(engine, surfacePick.biome()), "child", null, List.of(), Double.NaN));
}
return path;
}
// A shore biome sits on land the continent stream calls land or sea, so the biome's own type says it best.
private static String terrainKindName(IrisBiome surfaceBiome, InferredType continentKind) {
InferredType kind = surfaceBiome != null && surfaceBiome.getInferredType() != null
? surfaceBiome.getInferredType()
: continentKind;
if (kind == null) {
private static PickStep regionStep(Engine engine, IrisRegion region, double x, double z) {
IrisComplex complex = engine.getComplex();
double noise = complex.getRegionStyleStream().get(x, z);
List<Option> regions = complex.noisePickedRegions().stream()
.map(candidate -> PickMeasuring.regionOption(engine, candidate))
.toList();
return new PickStep(PickMeasuring.regionOption(engine, region), "region", noiseText(noise),
NoisePickSpread.splitNoise(regions, complex.isLegacyRarity()), noise);
}
private static PickStep previewedRegionStep(Engine engine, IrisRegion region) {
return new PickStep(PickMeasuring.regionOption(engine, region), "previewed", null, List.of(), Double.NaN);
}
private static PickStep noiseStep(Engine engine, IrisRegion region, InferredType type, double x, double z) {
IrisComplex complex = engine.getComplex();
double noise = complex.biomePickNoise(region, type, x, z);
List<Option> biomes = complex.noisePickedBiomes(region, type).stream()
.map(biome -> PickMeasuring.biomeOption(engine, biome))
.toList();
IrisBiome picked = complex.biomePickedByNoise(region, type, x, z);
return new PickStep(PickMeasuring.biomeOption(engine, picked), typeName(type), noiseText(noise),
NoisePickSpread.splitNoise(biomes, complex.isLegacyRarity()), noise);
}
// HYBRID terrain picks by the height a spot is placed at, and only competes where biome ranges share that height.
private static PickStep heightStep(Engine engine, IrisRegion region, InferredType type, double x, double z) {
HybridBiomeSelector.Choice choice = engine.getComplex().explainHybridChoice(region, type, x, z);
if (choice == null) {
return null;
}
String name = kind.name().toLowerCase(Locale.ROOT);
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
return new PickStep(PickMeasuring.biomeOption(engine, choice.biome()), typeName(type) + ", " + competitionText(choice),
signedHeightText(choice.placementHeight()), List.of(), Double.NaN);
}
private static String competitionText(HybridBiomeSelector.Choice choice) {
if (choice.fallback()) {
return "nearest range";
}
if (choice.rivalCount() == 0) {
return "only fit";
}
return "beat " + choice.rivalCount();
}
private static String typeName(InferredType type) {
return type.name().toLowerCase(Locale.ROOT);
}
private static String noiseText(double noise) {
return String.format(Locale.ROOT, "%.3f", noise);
}
private static String signedHeightText(double heightAboveSea) {
long rounded = Math.round(heightAboveSea);
return rounded < 0 ? "−" + -rounded : "+" + rounded;
}
record Details(
@@ -104,21 +155,24 @@ final class MapHoverProbe {
IrisBiome surfaceBiome,
IrisBiome caveBiome,
IrisRegion region,
String terrainKind,
String vanillaBiome,
double height,
double fluidHeight,
double slopeDegrees,
HybridBiomeSelector.Choice placement,
PickNoise pickNoise
List<PickStep> pickPath
) {
}
/**
* @param regionNoise the region noise value, or null when the region preview chose the region
* @param biomeType the biome list the biome noise picked from, or null when no noise picked the biome
* @param noisePick the biome the noise picked, which a child biome can still replace
* One step on the way to the spot's biome, starting with its region.
*
* @param reason what picked it: its kind, the biome list the noise picked from, or "child"
* @param value the noise value or placement height that picked it, or null
* @param bands the noise bands of all the options the noise picked from, empty when no noise picked it
*/
record PickNoise(Double regionNoise, InferredType biomeType, double biomeNoise, IrisBiome noisePick) {
record PickStep(Option picked, String reason, String value, List<Band> bands, double noise) {
boolean showsBands() {
return !bands.isEmpty();
}
}
}
@@ -48,7 +48,14 @@ final class NoisePickSpread {
* @param legacyRarity whether the dimension splits the noise the legacy way
*/
static NoisePickSpread of(String title, List<Option> options, boolean legacyRarity) {
return new NoisePickSpread(title, legacyRarity ? legacyBands(options) : bands(options));
return new NoisePickSpread(title, splitNoise(options, legacyRarity));
}
/**
* The band of noise each option gets, in the order the noise meets them.
*/
static List<Band> splitNoise(List<Option> options, boolean legacyRarity) {
return legacyRarity ? legacyBands(options) : bands(options);
}
void add(double noise) {
@@ -58,6 +58,11 @@ final class PickMeasuring {
return new Option(region.getLoadKey(), region.getName(), regionColor(engine, region), region.getRarity());
}
static Option biomeOption(Engine engine, IrisBiome biome) {
return new Option(biome.getLoadKey(), biome.getName(), biome.getColor(engine, RenderType.BIOME).getRGB() & 0xFFFFFF,
biome.getRarity());
}
static int regionColor(Engine engine, IrisRegion region) {
return region.getColor(engine.getComplex(), RenderType.REGION).getRGB() & 0xFFFFFF;
}
@@ -161,11 +166,6 @@ final class PickMeasuring {
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
private static Option biomeOption(Engine engine, IrisBiome biome) {
return new Option(biome.getLoadKey(), biome.getName(), biome.getColor(engine, RenderType.BIOME).getRGB() & 0xFFFFFF,
biome.getRarity());
}
@FunctionalInterface
interface SampleVisitor {
void visit(double x, double z);
@@ -18,26 +18,47 @@
package com.volmit.iris.core.gui;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.RoundRectangle2D;
/**
* Draws the floating cards (legends, hover details, hints) on top of a map, styled like IntelliJ's popups.
*/
final class StudioOverlay {
private static final Color FOOTER_BACKGROUND = new ThemeColor(0xF7F8FA, 0x27292C);
private StudioOverlay() {
}
static void drawCard(Graphics2D canvas, int x, int y, int width, int height) {
Object antialiasing = canvas.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
RoundRectangle2D card = new RoundRectangle2D.Double(x + 0.5D, y + 0.5D, width - 1D, height - 1D,
drawCard(canvas, x, y, width, height, 0);
}
/**
* Draws a card whose bottom {@code footerHeight} pixels are a darker bar, like the shortcut bar of IntelliJ's popups.
*/
static void drawCard(Graphics2D canvas, int x, int y, int width, int height, int footerHeight) {
Graphics2D card = (Graphics2D) canvas.create();
card.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
RoundRectangle2D outline = new RoundRectangle2D.Double(x + 0.5D, y + 0.5D, width - 1D, height - 1D,
StudioTheme.scale(StudioTheme.OVERLAY_ARC), StudioTheme.scale(StudioTheme.OVERLAY_ARC));
canvas.setColor(StudioTheme.OVERLAY_BACKGROUND);
canvas.fill(card);
canvas.setColor(StudioTheme.OVERLAY_BORDER);
canvas.draw(card);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antialiasing);
card.setColor(StudioTheme.OVERLAY_BACKGROUND);
card.fill(outline);
if (footerHeight > 0) {
int footerTop = y + height - footerHeight;
Shape cardClip = card.getClip();
card.clip(outline);
card.setColor(FOOTER_BACKGROUND);
card.fillRect(x, footerTop, width, footerHeight);
card.setColor(StudioTheme.OVERLAY_BORDER);
card.fillRect(x, footerTop, width, 1);
card.setClip(cardClip);
}
card.setColor(StudioTheme.OVERLAY_BORDER);
card.draw(outline);
card.dispose();
}
}
@@ -328,14 +328,14 @@ public class IrisComplex implements DataProvider {
}
/**
* Why HYBRID terrain gives a spot its base biome when the spot belongs to the given region; null for BIOME terrain.
* Why HYBRID terrain picks a biome from the region's biomes of this type at a spot; null for BIOME terrain.
*/
public HybridBiomeSelector.Choice explainHybridChoice(IrisRegion region, double x, double z) {
public HybridBiomeSelector.Choice explainHybridChoice(IrisRegion region, InferredType type, double x, double z) {
if (!hybridTerrain || focusBiome != null) {
return null;
}
return chooseHybridBiome(region, bridgeStream.get(x, z), x, z);
return chooseHybridBiome(region, type, x, z);
}
private IrisBiome biomeInRegion(IrisRegion region, InferredType type, double x, double z) {