From 86ef09e7683c33b409f96b0ec660c681a8b54e08 Mon Sep 17 00:00:00 2001 From: piexel Date: Wed, 21 Oct 2026 21:32:03 +0200 Subject: [PATCH] Show the pick path in the hover card Biome title, pick tree with noise bands. --- .../com/volmit/iris/core/gui/MapCanvas.java | 7 +- .../volmit/iris/core/gui/MapHoverCard.java | 544 ++++++++++++------ .../volmit/iris/core/gui/MapHoverProbe.java | 134 +++-- .../volmit/iris/core/gui/NoisePickSpread.java | 9 +- .../volmit/iris/core/gui/PickMeasuring.java | 10 +- .../volmit/iris/core/gui/StudioOverlay.java | 37 +- .../com/volmit/iris/engine/IrisComplex.java | 6 +- 7 files changed, 522 insertions(+), 225 deletions(-) diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java index d90259cce..e51e3cd28 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java @@ -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; } diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java index d5d8ef01f..eb803be03 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java @@ -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
sections, String footer) { +record MapHoverCard(Header header, String subtitle, String pathTitle, List path, boolean showsBands, + List groups, List 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
sections = new ArrayList<>(); - if (details != null) { - sections.add(terrainSection(details, type)); - addPickSection(sections, details); - } - sections.add(locationSection(worldX, worldZ, detailed)); + List path = details == null ? List.of() : details.pickPath(); + IrisBiome biome = cardBiome(subject, details, type); + List 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 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 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 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 path) { + return path.size() > 1 ? "Picked by" : "Region"; + } + + private static void addTerrainGroup(List groups, MapHoverProbe.Details details, RenderType type) { + if (details == null) { + return; + } List 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
sections, MapHoverProbe.Details details) { - List 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 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
sections, MapLabels.Label subject, MapLabels.Label region) { + private static void addFilesGroup(List groups, IrisBiome biome, Object region) { List 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 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 hints(RenderType type, boolean detailed) { + List 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
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 rows) { } - record Section(String title, List rows) { + record Row(String label, String value, boolean link) { } - record Row(String label, String value) { + record Hint(String key, String action) { } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java index 0313e3ba3..9b23a76e0 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java @@ -18,6 +18,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 pickPath(Engine engine, IrisRegion region, boolean previewed, + IrisComplex.BiomePick surfacePick, double x, double z) { + List 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