diff --git a/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java b/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java
index 6c9f5b7ff..bec6d1dc3 100644
--- a/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java
+++ b/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java
@@ -259,7 +259,7 @@ public class CommandStudio implements DecreeExecutor {
) {
if (noGUI()) return;
sender().sendMessage(C.GREEN + "Opening Palette Explorer!");
- NoiseExplorerGUI.launchPalettes(biome == null ? null : biome.getLoadKey());
+ NoiseExplorerGUI.launchPalettes(biome == null ? null : biome.getLoadKey(), sender().isPlayer() ? player().getUniqueId() : null);
}
@Decree(description = "Charges all spawners in the area", aliases = "zzt", origin = DecreeOrigin.PLAYER)
diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java
new file mode 100644
index 000000000..0d1601e15
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java
@@ -0,0 +1,381 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.bukkit.block.data.BlockData;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+final class BlockTextures {
+ private static final String DEFAULT_NAMESPACE = "minecraft";
+ private static final int MAX_MODEL_PARENTS = 16;
+ private static final int MAX_TEXTURE_REFERENCES = 16;
+ private static final int GRASS_TINT = 0x91BD59;
+ private static final int FOLIAGE_TINT = 0x77AB2F;
+ private static final int BIRCH_FOLIAGE_TINT = 0x80A755;
+ private static final int SPRUCE_FOLIAGE_TINT = 0x619961;
+ private static final int WATER_TINT = 0x3F76E4;
+ private static final Object SHARED_LOCK = new Object();
+ private static CompletableFuture shared;
+
+ private final ZipFile clientJar;
+ private final Map> topTextures = new ConcurrentHashMap<>();
+
+ private BlockTextures(ZipFile clientJar) {
+ this.clientJar = clientJar;
+ }
+
+ static CompletableFuture shared() {
+ synchronized (SHARED_LOCK) {
+ if (shared == null || shared.isCompletedExceptionally()) {
+ shared = MinecraftClientJar.open().thenApply(BlockTextures::new);
+ }
+ return shared;
+ }
+ }
+
+ Optional topTexture(BlockData block) {
+ return topTextures.computeIfAbsent(block.getAsString(true), this::resolveTopTexture);
+ }
+
+ private Optional resolveTopTexture(String blockState) {
+ try {
+ BlockStateId state = BlockStateId.parse(blockState);
+ JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
+ JsonObject variant = definition == null ? null : pickVariant(definition, state.properties());
+ if (variant == null || !variant.has("model")) {
+ return Optional.empty();
+ }
+ ModelFace face = findVisibleFace(variant.get("model").getAsString(), faceSeenFromAbove(variant));
+ if (face == null) {
+ return Optional.empty();
+ }
+ BufferedImage image = readImage(assetPath(face.texture(), "textures", ".png"));
+ if (image == null) {
+ return Optional.empty();
+ }
+ boolean tinted = face.tinted() || state.name().equals("water");
+ return Optional.of(BlockTexture.fromImage(image, tinted ? tintFor(state.name()) : BlockTexture.NO_TINT));
+ } catch (IOException | RuntimeException error) {
+ return Optional.empty();
+ }
+ }
+
+ // A block rotated on its x axis, like a sideways log, shows a side face on top.
+ private static String faceSeenFromAbove(JsonObject variant) {
+ int rotationX = variant.has("x") ? Math.floorMod(variant.get("x").getAsInt(), 360) : 0;
+ return switch (rotationX) {
+ case 90, 270 -> "north";
+ case 180 -> "down";
+ default -> "up";
+ };
+ }
+
+ private ModelFace findVisibleFace(String modelReference, String faceName) throws IOException {
+ Map textures = new HashMap<>();
+ JsonArray elements = null;
+ String current = modelReference;
+ for (int depth = 0; current != null && depth < MAX_MODEL_PARENTS; depth++) {
+ JsonObject model = readJson(assetPath(current, "models", ".json"));
+ if (model == null) {
+ break;
+ }
+ if (model.has("textures")) {
+ for (Map.Entry texture : model.getAsJsonObject("textures").entrySet()) {
+ textures.putIfAbsent(texture.getKey(), texture.getValue().getAsString());
+ }
+ }
+ if (elements == null && model.has("elements")) {
+ elements = model.getAsJsonArray("elements");
+ }
+ current = model.has("parent") ? model.get("parent").getAsString() : null;
+ }
+
+ JsonObject face = elements == null ? null : topmostFace(elements, faceName);
+ if (face == null) {
+ String particle = dereference("#particle", textures);
+ return particle == null ? null : new ModelFace(particle, elements != null && anyFaceTinted(elements));
+ }
+ String texture = dereference(face.get("texture").getAsString(), textures);
+ return texture == null ? null : new ModelFace(texture, face.has("tintindex"));
+ }
+
+ private static boolean anyFaceTinted(JsonArray elements) {
+ for (JsonElement element : elements) {
+ JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
+ if (faces == null) {
+ continue;
+ }
+ for (Map.Entry face : faces.entrySet()) {
+ if (face.getValue().getAsJsonObject().has("tintindex")) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static JsonObject topmostFace(JsonArray elements, String faceName) {
+ JsonObject best = null;
+ double bestHeight = Double.NEGATIVE_INFINITY;
+ double bestArea = -1D;
+ for (JsonElement element : elements) {
+ JsonObject cuboid = element.getAsJsonObject();
+ JsonObject faces = cuboid.getAsJsonObject("faces");
+ if (faces == null || !faces.has(faceName)) {
+ continue;
+ }
+ JsonArray from = cuboid.getAsJsonArray("from");
+ JsonArray to = cuboid.getAsJsonArray("to");
+ double height = to.get(1).getAsDouble();
+ double area = (to.get(0).getAsDouble() - from.get(0).getAsDouble()) * (to.get(2).getAsDouble() - from.get(2).getAsDouble());
+ if (height > bestHeight || (height == bestHeight && area > bestArea)) {
+ best = faces.getAsJsonObject(faceName);
+ bestHeight = height;
+ bestArea = area;
+ }
+ }
+ return best;
+ }
+
+ private static String dereference(String reference, Map textures) {
+ String resolved = reference;
+ for (int hop = 0; resolved != null && resolved.startsWith("#") && hop < MAX_TEXTURE_REFERENCES; hop++) {
+ resolved = textures.get(resolved.substring(1));
+ }
+ return resolved == null || resolved.startsWith("#") ? null : resolved;
+ }
+
+ private static JsonObject pickVariant(JsonObject definition, Map properties) {
+ if (definition.has("variants")) {
+ for (Map.Entry variant : definition.getAsJsonObject("variants").entrySet()) {
+ if (variantMatches(variant.getKey(), properties)) {
+ return firstModel(variant.getValue());
+ }
+ }
+ return null;
+ }
+ if (definition.has("multipart")) {
+ for (JsonElement part : definition.getAsJsonArray("multipart")) {
+ JsonObject rule = part.getAsJsonObject();
+ if (!rule.has("when") || conditionMatches(rule.getAsJsonObject("when"), properties)) {
+ return firstModel(rule.get("apply"));
+ }
+ }
+ }
+ return null;
+ }
+
+ private static boolean variantMatches(String variantKey, Map properties) {
+ if (variantKey.isEmpty()) {
+ return true;
+ }
+ for (String condition : variantKey.split(",")) {
+ String[] pair = condition.split("=", 2);
+ if (pair.length != 2 || !pair[1].equals(properties.get(pair[0]))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean conditionMatches(JsonObject condition, Map properties) {
+ if (condition.has("OR")) {
+ for (JsonElement option : condition.getAsJsonArray("OR")) {
+ if (conditionMatches(option.getAsJsonObject(), properties)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ if (condition.has("AND")) {
+ for (JsonElement requirement : condition.getAsJsonArray("AND")) {
+ if (!conditionMatches(requirement.getAsJsonObject(), properties)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ for (Map.Entry requirement : condition.entrySet()) {
+ String actual = properties.get(requirement.getKey());
+ boolean anyAllowed = false;
+ for (String allowed : requirement.getValue().getAsString().split("\\|")) {
+ anyAllowed |= allowed.equals(actual);
+ }
+ if (!anyAllowed) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static JsonObject firstModel(JsonElement models) {
+ if (models.isJsonArray()) {
+ JsonArray choices = models.getAsJsonArray();
+ return choices.isEmpty() ? null : choices.get(0).getAsJsonObject();
+ }
+ return models.getAsJsonObject();
+ }
+
+ private static int tintFor(String blockName) {
+ if (blockName.contains("water")) {
+ return WATER_TINT;
+ }
+ if (blockName.equals("birch_leaves")) {
+ return BIRCH_FOLIAGE_TINT;
+ }
+ if (blockName.equals("spruce_leaves")) {
+ return SPRUCE_FOLIAGE_TINT;
+ }
+ if (blockName.contains("leaves") || blockName.contains("vine")) {
+ return FOLIAGE_TINT;
+ }
+ return GRASS_TINT;
+ }
+
+ private static String assetPath(String resource, String folder, String extension) {
+ int separator = resource.indexOf(':');
+ String namespace = separator < 0 ? DEFAULT_NAMESPACE : resource.substring(0, separator);
+ String path = separator < 0 ? resource : resource.substring(separator + 1);
+ return "assets/" + namespace + "/" + folder + "/" + path + extension;
+ }
+
+ private JsonObject readJson(String path) throws IOException {
+ ZipEntry entry = clientJar.getEntry(path);
+ if (entry == null) {
+ return null;
+ }
+ try (InputStreamReader reader = new InputStreamReader(clientJar.getInputStream(entry), StandardCharsets.UTF_8)) {
+ return JsonParser.parseReader(reader).getAsJsonObject();
+ }
+ }
+
+ private BufferedImage readImage(String path) throws IOException {
+ ZipEntry entry = clientJar.getEntry(path);
+ if (entry == null) {
+ return null;
+ }
+ try (InputStream input = clientJar.getInputStream(entry)) {
+ return ImageIO.read(input);
+ }
+ }
+
+ private record ModelFace(String texture, boolean tinted) {
+ }
+
+ private record BlockStateId(String namespace, String name, Map properties) {
+ private static BlockStateId parse(String blockState) {
+ int propertiesStart = blockState.indexOf('[');
+ String id = propertiesStart < 0 ? blockState : blockState.substring(0, propertiesStart);
+ int separator = id.indexOf(':');
+ String namespace = separator < 0 ? DEFAULT_NAMESPACE : id.substring(0, separator);
+ String name = separator < 0 ? id : id.substring(separator + 1);
+ Map properties = new HashMap<>();
+ if (propertiesStart >= 0) {
+ String body = blockState.substring(propertiesStart + 1, blockState.length() - 1);
+ for (String pair : body.split(",")) {
+ String[] keyValue = pair.split("=", 2);
+ if (keyValue.length == 2) {
+ properties.put(keyValue[0], keyValue[1]);
+ }
+ }
+ }
+ return new BlockStateId(namespace, name, properties);
+ }
+ }
+
+ record BlockTexture(int size, int[] pixels, int averageColor) {
+ static final int NO_TINT = 0xFFFFFF;
+ private static final int OPAQUE_ALPHA = 128;
+ private static final double GAP_SHADE = 0.55D;
+
+ // Animated textures stack their frames vertically, so only the first square frame is used.
+ static BlockTexture fromImage(BufferedImage image, int tint) {
+ int size = image.getWidth();
+ int[] pixels = new int[size * size];
+ boolean[] opaque = new boolean[pixels.length];
+ long red = 0;
+ long green = 0;
+ long blue = 0;
+ int opaqueCount = 0;
+ for (int z = 0; z < size; z++) {
+ for (int x = 0; x < size; x++) {
+ int argb = image.getRGB(x, Math.min(z, image.getHeight() - 1));
+ int index = z * size + x;
+ if ((argb >>> 24) < OPAQUE_ALPHA) {
+ continue;
+ }
+ int color = applyTint(argb & 0xFFFFFF, tint);
+ pixels[index] = color;
+ opaque[index] = true;
+ red += (color >>> 16) & 0xFF;
+ green += (color >>> 8) & 0xFF;
+ blue += color & 0xFF;
+ opaqueCount++;
+ }
+ }
+ if (opaqueCount == 0) {
+ throw new IllegalArgumentException("Texture has no visible pixels");
+ }
+ int average = (int) (red / opaqueCount) << 16 | (int) (green / opaqueCount) << 8 | (int) (blue / opaqueCount);
+ int gapColor = BlockPreviewColors.shade(average, GAP_SHADE);
+ for (int index = 0; index < pixels.length; index++) {
+ if (!opaque[index]) {
+ pixels[index] = gapColor;
+ }
+ }
+ return new BlockTexture(size, pixels, average);
+ }
+
+ int texelAt(double worldX, double worldZ) {
+ int x = Math.min(size - 1, (int) ((worldX - Math.floor(worldX)) * size));
+ int z = Math.min(size - 1, (int) ((worldZ - Math.floor(worldZ)) * size));
+ return pixels[z * size + x];
+ }
+
+ BufferedImage toImage() {
+ BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
+ image.setRGB(0, 0, size, size, pixels, 0, size);
+ return image;
+ }
+
+ private static int applyTint(int color, int tint) {
+ int red = ((color >>> 16) & 0xFF) * ((tint >>> 16) & 0xFF) / 255;
+ int green = ((color >>> 8) & 0xFF) * ((tint >>> 8) & 0xFF) / 255;
+ int blue = (color & 0xFF) * (tint & 0xFF) / 255;
+ return (red << 16) | (green << 8) | blue;
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MinecraftClientJar.java b/core/src/main/java/com/volmit/iris/core/gui/MinecraftClientJar.java
new file mode 100644
index 000000000..a2c759e47
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/MinecraftClientJar.java
@@ -0,0 +1,145 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.volmit.iris.Iris;
+import org.bukkit.Bukkit;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.HexFormat;
+import java.util.concurrent.CompletableFuture;
+import java.util.zip.ZipFile;
+
+final class MinecraftClientJar {
+ private static final URI VERSION_MANIFEST = URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
+ private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10);
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(CONNECT_TIMEOUT)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+
+ private MinecraftClientJar() {
+ }
+
+ static boolean isCached() {
+ return cachedJar().exists();
+ }
+
+ static CompletableFuture open() {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ File jar = cachedJar();
+ if (!jar.exists()) {
+ download(Bukkit.getMinecraftVersion(), jar.toPath());
+ }
+ return new ZipFile(jar);
+ } catch (IOException | InterruptedException error) {
+ throw new IllegalStateException(readableReason(error), error);
+ }
+ }, MinecraftClientJar::runInBackground);
+ }
+
+ private static void runInBackground(Runnable task) {
+ Thread thread = new Thread(task, "Iris Minecraft Client Download");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ private static File cachedJar() {
+ return Iris.instance.getDataFile("cache", "minecraft-client", Bukkit.getMinecraftVersion() + ".jar");
+ }
+
+ private static void download(String version, Path destination) throws IOException, InterruptedException {
+ JsonObject versionInfo = fetchJson(findVersionUrl(version));
+ JsonObject client = versionInfo.getAsJsonObject("downloads").getAsJsonObject("client");
+ URI clientUrl = URI.create(client.get("url").getAsString());
+ String expectedSha1 = client.get("sha1").getAsString();
+
+ Path partial = destination.resolveSibling(destination.getFileName() + ".part");
+ HttpResponse response = HTTP.send(
+ HttpRequest.newBuilder(clientUrl).timeout(DOWNLOAD_TIMEOUT).build(),
+ HttpResponse.BodyHandlers.ofInputStream()
+ );
+ requireSuccess(response.statusCode(), clientUrl);
+ String actualSha1;
+ try (DigestInputStream body = new DigestInputStream(response.body(), sha1())) {
+ Files.copy(body, partial, StandardCopyOption.REPLACE_EXISTING);
+ actualSha1 = HexFormat.of().formatHex(body.getMessageDigest().digest());
+ }
+ if (!actualSha1.equalsIgnoreCase(expectedSha1)) {
+ Files.deleteIfExists(partial);
+ throw new IOException("the downloaded client jar was damaged (checksum mismatch)");
+ }
+ Files.move(partial, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ }
+
+ private static URI findVersionUrl(String version) throws IOException, InterruptedException {
+ for (var entry : fetchJson(VERSION_MANIFEST).getAsJsonArray("versions")) {
+ JsonObject candidate = entry.getAsJsonObject();
+ if (candidate.get("id").getAsString().equals(version)) {
+ return URI.create(candidate.get("url").getAsString());
+ }
+ }
+ throw new IOException("Mojang doesn't list Minecraft " + version);
+ }
+
+ private static JsonObject fetchJson(URI url) throws IOException, InterruptedException {
+ HttpResponse response = HTTP.send(
+ HttpRequest.newBuilder(url).timeout(CONNECT_TIMEOUT).build(),
+ HttpResponse.BodyHandlers.ofString()
+ );
+ requireSuccess(response.statusCode(), url);
+ return JsonParser.parseString(response.body()).getAsJsonObject();
+ }
+
+ private static void requireSuccess(int statusCode, URI url) throws IOException {
+ if (statusCode / 100 != 2) {
+ throw new IOException(url.getHost() + " answered HTTP " + statusCode);
+ }
+ }
+
+ private static MessageDigest sha1() {
+ try {
+ return MessageDigest.getInstance("SHA-1");
+ } catch (NoSuchAlgorithmException error) {
+ throw new IllegalStateException(error);
+ }
+ }
+
+ private static String readableReason(Throwable error) {
+ String message = error.getMessage();
+ return message == null || message.isBlank() ? error.getClass().getSimpleName() : message;
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java
index a10fcb369..cd9129870 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java
@@ -23,6 +23,10 @@ import java.awt.Color;
interface NoiseColorScale {
int colorFinite(double value);
+ default int colorAt(double value, double worldX, double worldZ) {
+ return colorFinite(value);
+ }
+
int colorInvalid();
Color displayColorNormalized(double normalized);
diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java
index e8c40e3e9..96a12765c 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java
@@ -23,7 +23,6 @@ import com.volmit.iris.core.IrisSettings;
import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
-import com.volmit.iris.engine.object.IrisBiomePaletteLayer;
import com.volmit.iris.engine.object.IrisGenerator;
import com.volmit.iris.engine.object.NoiseStyle;
import com.volmit.iris.util.function.NoiseProvider;
@@ -73,6 +72,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
+import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
@@ -104,7 +104,7 @@ public final class NoiseExplorerGUI extends JPanel {
private static final int STATUS_HEIGHT = 50;
private static final int REFINEMENT_DELAY_MILLIS = 180;
private static final long DEFAULT_SEED = 12345L;
- private static final int SUMMARIZED_BLOCK_COUNT = 3;
+ private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D;
private static final long COARSE_SAMPLE_BUDGET = 4_096L;
private static final long MAX_REFINED_SAMPLE_BUDGET = 48_000L;
private static final double REFINEMENT_TARGET_MILLIS = 250D;
@@ -153,6 +153,10 @@ public final class NoiseExplorerGUI extends JPanel {
private final JLabel catalogLabel = new JLabel("Pack catalog waiting");
private final JComboBox palettePicker = new JComboBox<>(NoisePalette.values());
private final PaletteInspectorPanel paletteInspector = new PaletteInspectorPanel(new PaletteInspectorListener());
+ private final PaletteNavigator paletteNavigator;
+ private final UUID trackedPlayerId;
+ private PlayerLocationTracker locationTracker;
+ private volatile BlockTextures blockTextures;
private List packSources = List.of();
private NoiseViewport viewport = new NoiseViewport(0D, 0D, 1D);
@@ -175,8 +179,10 @@ public final class NoiseExplorerGUI extends JPanel {
private String errorMessage;
private NoiseExplorerGUI(ExplorerMode mode, Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator,
- String customName, long initialSeed, String initialPackSourceId) {
+ String customName, long initialSeed, String initialPackSourceId, UUID trackedPlayerId) {
this.mode = mode;
+ this.trackedPlayerId = trackedPlayerId;
+ this.paletteNavigator = new PaletteNavigator(trackedPlayerId != null, this::selectPaletteLayer);
this.initialPackSourceId = initialPackSourceId;
this.awaitingInitialPackSource = mode == ExplorerMode.PALETTES;
this.activeEngine = engine;
@@ -206,17 +212,17 @@ public final class NoiseExplorerGUI extends JPanel {
public static void launch() {
Engine engine = GuiHost.findActiveEngine();
EventQueue.invokeLater(() -> {
- NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.NOISE, engine, null, null, null, DEFAULT_SEED, null);
+ NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.NOISE, engine, null, null, null, DEFAULT_SEED, null, null);
buildFrame("Noise Explorer", explorer);
});
}
- public static void launchPalettes(String biomeKey) {
+ public static void launchPalettes(String biomeKey, UUID trackedPlayerId) {
Engine engine = GuiHost.findActiveEngine();
String initialSourceId = biomeKey == null ? null : paletteSourceId(biomeKey, 0);
String title = biomeKey == null ? "Palette Explorer" : "Palette Explorer: " + biomeKey;
EventQueue.invokeLater(() -> {
- NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.PALETTES, engine, null, null, null, DEFAULT_SEED, initialSourceId);
+ NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.PALETTES, engine, null, null, null, DEFAULT_SEED, initialSourceId, trackedPlayerId);
buildFrame(title, explorer);
});
}
@@ -232,6 +238,7 @@ public final class NoiseExplorerGUI extends JPanel {
fallbackGenerator,
displayName,
initialSeed,
+ null,
null
);
buildFrame("Noise Explorer: " + displayName, explorer);
@@ -326,7 +333,9 @@ public final class NoiseExplorerGUI extends JPanel {
JPanel north = new JPanel(new BorderLayout());
north.setBackground(SIDEBAR_BACKGROUND);
north.add(heading, BorderLayout.NORTH);
- north.add(searchField, BorderLayout.SOUTH);
+ if (mode == ExplorerMode.NOISE) {
+ north.add(searchField, BorderLayout.SOUTH);
+ }
sourceList.setBackground(SIDEBAR_BACKGROUND);
sourceList.setForeground(SECONDARY_TEXT);
@@ -347,7 +356,7 @@ public final class NoiseExplorerGUI extends JPanel {
JPanel controls = buildControls();
sidebar.add(north, BorderLayout.NORTH);
- sidebar.add(scrollPane, BorderLayout.CENTER);
+ sidebar.add(mode == ExplorerMode.PALETTES ? paletteNavigator : scrollPane, BorderLayout.CENTER);
sidebar.add(controls, BorderLayout.SOUTH);
populateSourceList();
return sidebar;
@@ -432,6 +441,12 @@ public final class NoiseExplorerGUI extends JPanel {
void openViewer() {
loadCatalog();
+ if (mode == ExplorerMode.PALETTES) {
+ loadBlockTextures();
+ if (trackedPlayerId != null) {
+ locationTracker = PlayerLocationTracker.follow(trackedPlayerId, paletteNavigator::showLocation);
+ }
+ }
SourceItem initial = findInitialSource();
if (initial != null && !selectSourceInList(initial.id())) {
selectSource(initial);
@@ -497,10 +512,11 @@ public final class NoiseExplorerGUI extends JPanel {
try {
Engine engine = resolveEngine();
List loadedSources = loadPackSources(engine);
- EventQueue.invokeLater(() -> applyCatalog(revision, loadedSources, null));
+ PaletteCatalog paletteCatalog = mode == ExplorerMode.PALETTES && engine != null ? PaletteCatalog.load(engine) : null;
+ EventQueue.invokeLater(() -> applyCatalog(revision, loadedSources, paletteCatalog, null));
} catch (Throwable error) {
Iris.reportError(error);
- EventQueue.invokeLater(() -> applyCatalog(revision, List.of(), error));
+ EventQueue.invokeLater(() -> applyCatalog(revision, List.of(), null, error));
}
});
}
@@ -562,7 +578,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
// Palette layers always use the world's terrain seed: they share their cached noise with the running world.
- private static List loadPackPaletteSources(Engine engine) {
+ private List loadPackPaletteSources(Engine engine) {
IrisData data = engine.getData();
String[] keys = data.getBiomeLoader().getPossibleKeys();
if (keys == null || keys.length == 0) {
@@ -579,12 +595,12 @@ public final class NoiseExplorerGUI extends JPanel {
int selectedLayer = layer;
sources.add(new SourceItem(
paletteSourceId(key, layer),
- "Layer " + layer + " · " + summarizeBlocks(biome.getLayers().get(layer)),
+ "Layer " + layer + " · " + PaletteCatalog.summarizeBlocks(biome.getLayers().get(layer)),
key,
NoiseCategory.PACK_PALETTES,
false,
(sourceSeed, depth) -> {
- PaletteLayerPreview preview = PaletteLayerPreview.load(engine, key, selectedLayer, depth);
+ PaletteLayerPreview preview = PaletteLayerPreview.load(engine, key, selectedLayer, depth, blockTextures);
return new LoadedSource(preview::sample, preview);
}
));
@@ -597,24 +613,49 @@ public final class NoiseExplorerGUI extends JPanel {
return "palette:" + biomeKey + "#" + layer;
}
- private static String summarizeBlocks(IrisBiomePaletteLayer layer) {
- List names = layer.getPalette().stream()
- .map(block -> block.getBlock().replace("minecraft:", ""))
- .distinct()
- .toList();
- if (names.size() <= SUMMARIZED_BLOCK_COUNT) {
- return String.join(", ", names);
+ private void selectPaletteLayer(String biomeKey, int layer) {
+ SourceItem source = findSourceById(paletteSourceId(biomeKey, layer), packSources);
+ if (source != null && source != selectedSource) {
+ selectSource(source);
}
- return String.join(", ", names.subList(0, SUMMARIZED_BLOCK_COUNT))
- + " +" + (names.size() - SUMMARIZED_BLOCK_COUNT) + " more";
}
- private void applyCatalog(long revision, List sources, Throwable error) {
+ private static int paletteLayerOf(SourceItem source) {
+ return Integer.parseInt(source.id().substring(source.id().lastIndexOf('#') + 1));
+ }
+
+ private void loadBlockTextures() {
+ paletteInspector.showTextureStatus(MinecraftClientJar.isCached()
+ ? "Loading block textures..."
+ : "Downloading block textures from Mojang. This happens once and is about 40 MB.");
+ BlockTextures.shared().whenComplete((textures, error) -> EventQueue.invokeLater(() -> applyBlockTextures(textures, error)));
+ }
+
+ private void applyBlockTextures(BlockTextures textures, Throwable error) {
+ if (disposed) {
+ return;
+ }
+ if (error != null) {
+ Throwable reason = error.getCause() == null ? error : error.getCause();
+ paletteInspector.showTextureStatus("Block textures unavailable (" + readableError(reason) + "). Showing map colours instead.");
+ return;
+ }
+ blockTextures = textures;
+ paletteInspector.showTextureStatus(null);
+ if (palettePreview != null) {
+ loadSelectedSource();
+ }
+ }
+
+ private void applyCatalog(long revision, List sources, PaletteCatalog paletteCatalog, Throwable error) {
if (disposed || revision != catalogLoadRevision.get()) {
return;
}
catalogLoading = false;
packSources = sources;
+ if (paletteCatalog != null) {
+ paletteNavigator.showCatalog(paletteCatalog);
+ }
if (error != null) {
catalogLabel.setForeground(ERROR);
catalogLabel.setText("Pack catalog unavailable");
@@ -687,6 +728,9 @@ public final class NoiseExplorerGUI extends JPanel {
EventQueue.invokeLater(this::populateSourceList);
return;
}
+ if (mode == ExplorerMode.PALETTES) {
+ return;
+ }
String selectedId = selectedSource == null ? null : selectedSource.id();
String filter = searchField.getText().trim().toLowerCase(Locale.ROOT);
sourceModel.clear();
@@ -695,10 +739,6 @@ public final class NoiseExplorerGUI extends JPanel {
if (matching.isEmpty()) {
continue;
}
- if (category == NoiseCategory.PACK_PALETTES) {
- addGroupedByBiome(matching);
- continue;
- }
sourceModel.addElement(new ListItem(categoryLabel(category), null));
for (SourceItem source : matching) {
sourceModel.addElement(new ListItem(source.text(), source));
@@ -709,17 +749,6 @@ public final class NoiseExplorerGUI extends JPanel {
}
}
- private void addGroupedByBiome(List paletteSources) {
- String currentBiome = null;
- for (SourceItem source : paletteSources) {
- if (!source.rawName().equals(currentBiome)) {
- currentBiome = source.rawName();
- sourceModel.addElement(new ListItem(currentBiome, null));
- }
- sourceModel.addElement(new ListItem(source.text(), source));
- }
- }
-
private List matchingSources(NoiseCategory category, String filter) {
List matching = new ArrayList<>();
addMatchingSources(matching, fixedSources, category, filter);
@@ -759,6 +788,9 @@ public final class NoiseExplorerGUI extends JPanel {
selectedSource = source;
paletteDepth = 0;
selectedLabel.setText(source.category() == NoiseCategory.PACK_PALETTES ? source.rawName() : source.text());
+ if (source.category() == NoiseCategory.PACK_PALETTES) {
+ paletteNavigator.markSelected(source.rawName(), paletteLayerOf(source));
+ }
loadSelectedSource();
}
@@ -844,7 +876,11 @@ public final class NoiseExplorerGUI extends JPanel {
private NoiseColorScale activeColorScale() {
PaletteLayerPreview preview = palettePreview;
- return preview == null ? palette : new PaletteLayerScale(paletteInspector.selectedView(), preview, palette);
+ if (preview == null) {
+ return palette;
+ }
+ boolean showTexturePixels = viewport.blocksPerPixel() <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL;
+ return new PaletteLayerScale(paletteInspector.selectedView(), preview, palette, showTexturePixels);
}
private void applySeed() {
@@ -1028,6 +1064,9 @@ public final class NoiseExplorerGUI extends JPanel {
return;
}
disposed = true;
+ if (locationTracker != null) {
+ locationTracker.stop();
+ }
refinementTimer.stop();
interactionTimer.stop();
GuiHost.unregisterHotloadHook(hotloadHook);
diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java
index 7f6148539..26d985cc2 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java
@@ -238,7 +238,7 @@ final class NoiseRenderCoordinator implements AutoCloseable {
double value = request.sampler().noise(worldX, worldZ);
boolean center = centerRow && x == outputWidth / 2;
if (Double.isFinite(value)) {
- pixels[pixelIndex++] = colorScale.colorFinite(value);
+ pixels[pixelIndex++] = colorScale.colorAt(value, worldX, worldZ);
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
} else {
pixels[pixelIndex++] = colorScale.colorInvalid();
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java
new file mode 100644
index 000000000..3505928bd
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java
@@ -0,0 +1,134 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.loader.IrisData;
+import com.volmit.iris.engine.framework.Engine;
+import com.volmit.iris.engine.object.IrisBiome;
+import com.volmit.iris.engine.object.IrisBiomePaletteLayer;
+import com.volmit.iris.engine.object.IrisRegion;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+record PaletteCatalog(List regions, List biomesOutsideRegions, Map biomes) {
+ private static final int SUMMARIZED_BLOCK_COUNT = 3;
+
+ static PaletteCatalog load(Engine engine) {
+ IrisData data = engine.getData();
+ Map biomes = loadBiomes(data);
+ List regions = new ArrayList<>();
+ Set biomesInRegions = new HashSet<>();
+ for (String regionKey : engine.getDimension().getRegions()) {
+ IrisRegion region = data.getRegionLoader().load(regionKey);
+ if (region == null) {
+ continue;
+ }
+ RegionEntry entry = describeRegion(regionKey, region, biomes);
+ regions.add(entry);
+ entry.groups().forEach(group -> group.biomeKeys().forEach(key -> collectWithChildren(key, biomes, biomesInRegions)));
+ }
+ List outside = biomes.keySet().stream().filter(key -> !biomesInRegions.contains(key)).toList();
+ return new PaletteCatalog(List.copyOf(regions), outside, Map.copyOf(biomes));
+ }
+
+ BiomeEntry biome(String key) {
+ return biomes.get(key);
+ }
+
+ RegionEntry region(String key) {
+ return regions.stream().filter(region -> region.key().equals(key)).findFirst().orElse(null);
+ }
+
+ static String summarizeBlocks(IrisBiomePaletteLayer layer) {
+ List names = layer.getPalette().stream()
+ .map(block -> block.getBlock().replace("minecraft:", ""))
+ .distinct()
+ .toList();
+ if (names.size() <= SUMMARIZED_BLOCK_COUNT) {
+ return String.join(", ", names);
+ }
+ return String.join(", ", names.subList(0, SUMMARIZED_BLOCK_COUNT))
+ + " +" + (names.size() - SUMMARIZED_BLOCK_COUNT) + " more";
+ }
+
+ private static Map loadBiomes(IrisData data) {
+ String[] keys = data.getBiomeLoader().getPossibleKeys();
+ Map biomes = new LinkedHashMap<>();
+ if (keys == null) {
+ return biomes;
+ }
+ Arrays.sort(keys);
+ for (String key : keys) {
+ IrisBiome biome = data.getBiomeLoader().load(key);
+ if (biome != null) {
+ List layers = biome.getLayers().stream().map(PaletteCatalog::summarizeBlocks).toList();
+ biomes.put(key, new BiomeEntry(key, biome.getName(), layers, List.copyOf(biome.getChildren())));
+ }
+ }
+ return biomes;
+ }
+
+ private static RegionEntry describeRegion(String key, IrisRegion region, Map biomes) {
+ List groups = new ArrayList<>();
+ addGroup(groups, "Land", region.getLandBiomes(), biomes);
+ addGroup(groups, "Shore", region.getShoreBiomes(), biomes);
+ addGroup(groups, "Sea", region.getSeaBiomes(), biomes);
+ addGroup(groups, "Cave", region.getCaveBiomes(), biomes);
+ return new RegionEntry(key, region.getName(), List.copyOf(groups));
+ }
+
+ private static void addGroup(List groups, String label, List keys, Map biomes) {
+ List known = keys.stream().filter(biomes::containsKey).distinct().toList();
+ if (!known.isEmpty()) {
+ groups.add(new BiomeGroup(label, known));
+ }
+ }
+
+ private static void collectWithChildren(String key, Map biomes, Set collected) {
+ BiomeEntry biome = biomes.get(key);
+ if (biome == null || !collected.add(key)) {
+ return;
+ }
+ biome.childKeys().forEach(child -> collectWithChildren(child, biomes, collected));
+ }
+
+ record RegionEntry(String key, String name, List groups) {
+ List allBiomeKeys() {
+ return groups.stream().flatMap(group -> group.biomeKeys().stream()).distinct().toList();
+ }
+ }
+
+ record BiomeGroup(String label, List biomeKeys) {
+ }
+
+ record BiomeEntry(String key, String name, List layerSummaries, List childKeys) {
+ boolean matches(String filter) {
+ if (key.toLowerCase().contains(filter) || name.toLowerCase().contains(filter)) {
+ return true;
+ }
+ return layerSummaries.stream().anyMatch(summary -> summary.toLowerCase().contains(filter));
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java
index d63b81dff..62d81cf23 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java
@@ -97,6 +97,10 @@ final class PaletteInspectorPanel extends JPanel {
setVisible(true);
}
+ void showTextureStatus(String status) {
+ statisticsView.showTextureStatus(status);
+ }
+
void hideInspector() {
setVisible(false);
}
@@ -183,12 +187,13 @@ final class PaletteInspectorPanel extends JPanel {
private static final int HISTOGRAM_HEIGHT = 84;
private static final int BAND_HEIGHT = 12;
private static final int ROW_HEIGHT = 20;
- private static final int SWATCH_SIZE = 10;
+ private static final int SWATCH_SIZE = 14;
private static final int WEIGHT_COLUMN_WIDTH = 48;
private static final int ACTUAL_COLUMN_WIDTH = 70;
private static final int NOTE_LINE_HEIGHT = 15;
private PaletteLayerPreview preview;
+ private String textureStatus;
private StatisticsView() {
setOpaque(true);
@@ -201,6 +206,12 @@ final class PaletteInspectorPanel extends JPanel {
repaint();
}
+ private void showTextureStatus(String status) {
+ textureStatus = status;
+ revalidate();
+ repaint();
+ }
+
@Override
public Dimension getPreferredSize() {
if (preview == null) {
@@ -209,7 +220,7 @@ final class PaletteInspectorPanel extends JPanel {
int width = Math.max(getWidth(), PANEL_WIDTH - 20) - MARGIN * 2;
FontMetrics noteMetrics = getFontMetrics(LEGEND_FONT);
int noteLines = 0;
- for (String note : notes(preview)) {
+ for (String note : notes()) {
noteLines += wrap(note, noteMetrics, width).size() + 1;
}
int height = MARGIN + 18 + HISTOGRAM_HEIGHT + BAND_HEIGHT + 30
@@ -288,8 +299,7 @@ final class PaletteInspectorPanel extends JPanel {
int rowY = y + ROW_HEIGHT;
for (PaletteLayerPreview.Entry entry : preview.entries()) {
- canvas.setColor(new Color(entry.color()));
- canvas.fillRoundRect(x, rowY - SWATCH_SIZE, SWATCH_SIZE, SWATCH_SIZE, 3, 3);
+ drawSwatch(canvas, entry, x, rowY - SWATCH_SIZE + 2);
canvas.setFont(ITEM_FONT);
canvas.setColor(entry.found() ? PRIMARY_TEXT : NoiseExplorerGUI.ERROR);
String name = entry.found() ? shortBlockName(entry.block()) : shortBlockName(entry.block()) + " (not found)";
@@ -304,12 +314,21 @@ final class PaletteInspectorPanel extends JPanel {
return rowY - ROW_HEIGHT;
}
+ private static void drawSwatch(Graphics2D canvas, PaletteLayerPreview.Entry entry, int x, int y) {
+ if (entry.texture() != null) {
+ canvas.drawImage(entry.texture().toImage(), x, y, SWATCH_SIZE, SWATCH_SIZE, null);
+ return;
+ }
+ canvas.setColor(new Color(entry.color()));
+ canvas.fillRoundRect(x, y, SWATCH_SIZE, SWATCH_SIZE, 3, 3);
+ }
+
private void drawNotes(Graphics2D canvas, int x, int y, int width) {
canvas.setFont(LEGEND_FONT);
canvas.setColor(SECONDARY_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
int lineY = y;
- for (String note : notes(preview)) {
+ for (String note : notes()) {
for (String line : wrap(note, metrics, width)) {
canvas.drawString(line, x, lineY);
lineY += NOTE_LINE_HEIGHT;
@@ -318,8 +337,11 @@ final class PaletteInspectorPanel extends JPanel {
}
}
- private static List notes(PaletteLayerPreview preview) {
+ private List notes() {
List notes = new ArrayList<>();
+ if (textureStatus != null) {
+ notes.add(textureStatus);
+ }
notes.add("▼ gets under half its weight, ▲ gets over double.");
notes.add(String.format(Locale.ROOT, "Measured on a %d × %d grid spread over %,.0f blocks around 0, 0.",
PaletteLayerPreview.STATISTICS_GRID_SIZE, PaletteLayerPreview.STATISTICS_GRID_SIZE,
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java
index 9a4932d3b..0ce1191c6 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java
@@ -18,6 +18,7 @@
package com.volmit.iris.core.gui;
+import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
@@ -47,12 +48,14 @@ final class PaletteLayerPreview {
private final IrisData data;
private final int[] entryBySlot;
private final int[] colorByEntry;
+ private final BlockTexture[] textureByEntry;
private final List entries;
private final long[] histogram;
private final double absentShare;
private final double outOfRangeShare;
- private PaletteLayerPreview(String biomeKey, IrisBiome biome, int layer, int depth, RNG random, IrisData data) {
+ private PaletteLayerPreview(String biomeKey, IrisBiome biome, int layer, int depth, RNG random, IrisData data,
+ BlockTextures textures) {
this.biomeKey = biomeKey;
this.biome = biome;
this.paletteLayer = biome.getLayers().get(layer);
@@ -64,6 +67,7 @@ final class PaletteLayerPreview {
if (entryBySlot.length == 0) {
throw new IllegalStateException("No palette block in " + biomeKey + " layer " + layer + " could be found");
}
+ this.textureByEntry = resolveEntryTextures(textures);
this.colorByEntry = resolveEntryColors();
Statistics statistics = measure();
@@ -73,7 +77,7 @@ final class PaletteLayerPreview {
this.entries = describeEntries(statistics.sharesByEntry());
}
- static PaletteLayerPreview load(Engine engine, String biomeKey, int layer, int depth) {
+ static PaletteLayerPreview load(Engine engine, String biomeKey, int layer, int depth, BlockTextures textures) {
if (engine == null || engine.isClosed()) {
throw new IllegalStateException("Open a studio world to preview pack palettes");
}
@@ -83,7 +87,7 @@ final class PaletteLayerPreview {
throw new IllegalStateException("Palette layer removed during hotload: " + biomeKey + " layer " + layer);
}
RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain());
- return new PaletteLayerPreview(biomeKey, biome, layer, depth, terrainRandom, data);
+ return new PaletteLayerPreview(biomeKey, biome, layer, depth, terrainRandom, data, textures);
}
double sample(double x, double z) {
@@ -97,6 +101,10 @@ final class PaletteLayerPreview {
return colorByEntry[entryIndexForNoise(noise)];
}
+ BlockTexture textureForNoise(double noise) {
+ return textureByEntry[entryIndexForNoise(noise)];
+ }
+
Entry entryForNoise(double noise) {
return entries.get(entryIndexForNoise(noise));
}
@@ -153,20 +161,40 @@ final class PaletteLayerPreview {
return entryBySlot[IrisBiomePaletteLayer.slotForNoise(noise, entryBySlot.length)];
}
+ private BlockTexture[] resolveEntryTextures(BlockTextures textures) {
+ KList palette = paletteLayer.getPalette();
+ BlockTexture[] resolved = new BlockTexture[palette.size()];
+ if (textures == null) {
+ return resolved;
+ }
+ for (int entry = 0; entry < palette.size(); entry++) {
+ BlockData block = palette.get(entry).getBlockData(data);
+ resolved[entry] = block == null ? null : textures.topTexture(block).orElse(null);
+ }
+ return resolved;
+ }
+
+ // Blocks without a texture, like custom blocks or everything before the textures load, fall back to map colours.
private int[] resolveEntryColors() {
KList palette = paletteLayer.getPalette();
- List blocks = new ArrayList<>();
- for (IrisBlockData entry : palette) {
- BlockData block = entry.getBlockData(data);
- if (block != null) {
- blocks.add(block);
+ List untexturedBlocks = new ArrayList<>();
+ for (int entry = 0; entry < palette.size(); entry++) {
+ BlockData block = palette.get(entry).getBlockData(data);
+ if (block != null && textureByEntry[entry] == null) {
+ untexturedBlocks.add(block);
}
}
- Map colorsByBlock = BlockPreviewColors.colorsByBlock(blocks);
+ Map mapColorsByBlock = BlockPreviewColors.colorsByBlock(untexturedBlocks);
int[] colors = new int[palette.size()];
for (int entry = 0; entry < palette.size(); entry++) {
BlockData block = palette.get(entry).getBlockData(data);
- colors[entry] = block == null ? MISSING_BLOCK_COLOR : colorsByBlock.get(block.getAsString(true));
+ if (block == null) {
+ colors[entry] = MISSING_BLOCK_COLOR;
+ } else if (textureByEntry[entry] != null) {
+ colors[entry] = textureByEntry[entry].averageColor();
+ } else {
+ colors[entry] = mapColorsByBlock.get(block.getAsString(true));
+ }
}
return colors;
}
@@ -215,6 +243,7 @@ final class PaletteLayerPreview {
block.getWeight(),
block.getBlockData(data) != null,
colorByEntry[entry],
+ textureByEntry[entry],
placed ? (lastSlot - firstSlot + 1) / (double) slotCount : 0D,
sharesByEntry[entry],
placed ? slotLowerBound(firstSlot, slotCount) : Double.NaN,
@@ -251,8 +280,8 @@ final class PaletteLayerPreview {
return slot == slotCount - 1 ? 1D : (slot + 0.5D) / (slotCount - 1);
}
- record Entry(String block, int weight, boolean found, int color, double weightShare, double actualShare,
- double lowestNoise, double highestNoise) {
+ record Entry(String block, int weight, boolean found, int color, BlockTexture texture, double weightShare,
+ double actualShare, double lowestNoise, double highestNoise) {
boolean isFarBelowWeight() {
return found && actualShare < weightShare / FAR_FROM_WEIGHT_FACTOR;
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java
index 53aec82a2..a9d715847 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java
@@ -27,11 +27,23 @@ final class PaletteLayerScale implements NoiseColorScale {
private final View view;
private final PaletteLayerPreview preview;
private final NoisePalette noisePalette;
+ private final boolean showTexturePixels;
- PaletteLayerScale(View view, PaletteLayerPreview preview, NoisePalette noisePalette) {
+ PaletteLayerScale(View view, PaletteLayerPreview preview, NoisePalette noisePalette, boolean showTexturePixels) {
this.view = view;
this.preview = preview;
this.noisePalette = noisePalette;
+ this.showTexturePixels = showTexturePixels;
+ }
+
+ @Override
+ public int colorAt(double value, double worldX, double worldZ) {
+ BlockTextures.BlockTexture texture = showTexturePixels && view != View.NOISE ? preview.textureForNoise(value) : null;
+ if (texture == null) {
+ return colorFinite(value);
+ }
+ int texel = texture.texelAt(worldX, worldZ);
+ return view == View.BLOCKS ? texel : BlockPreviewColors.shade(texel, overlayShade(value));
}
@Override
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java
new file mode 100644
index 000000000..e6d06b7dc
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java
@@ -0,0 +1,513 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.PaletteCatalog.BiomeEntry;
+import com.volmit.iris.core.gui.PaletteCatalog.BiomeGroup;
+import com.volmit.iris.core.gui.PaletteCatalog.RegionEntry;
+import com.volmit.iris.core.gui.PlayerLocationTracker.PlayerLocation;
+
+import javax.swing.BorderFactory;
+import javax.swing.ButtonGroup;
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.DefaultListModel;
+import javax.swing.JCheckBox;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextField;
+import javax.swing.JToggleButton;
+import javax.swing.JTree;
+import javax.swing.ListSelectionModel;
+import javax.swing.ScrollPaneConstants;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.DefaultTreeCellRenderer;
+import javax.swing.tree.DefaultTreeModel;
+import javax.swing.tree.TreePath;
+import javax.swing.tree.TreeSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.CardLayout;
+import java.awt.Component;
+import java.awt.GridLayout;
+import java.awt.Rectangle;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.HEADER_FONT;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.PANEL_BACKGROUND;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
+import static com.volmit.iris.core.gui.NoiseExplorerGUI.SIDEBAR_BACKGROUND;
+
+final class PaletteNavigator extends JPanel {
+ private static final long serialVersionUID = -6402313718430917284L;
+ private static final String HERE_CARD = "here";
+ private static final String OVERVIEW_CARD = "overview";
+ private static final String DETAIL_COLOR = "#8890A0";
+
+ private final Listener listener;
+ private final boolean followsPlayer;
+ private final CardLayout cards = new CardLayout();
+ private final JPanel cardPanel = new JPanel(cards);
+ private final JLabel regionLabel = new JLabel();
+ private final JLabel biomeLabel = new JLabel();
+ private final JCheckBox followCheckBox = new JCheckBox("Follow me", true);
+ private final DefaultListModel hereRows = new DefaultListModel<>();
+ private final JList hereList = new JList<>(hereRows);
+ private final JTextField searchField = new JTextField();
+ private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
+ private final JTree overviewTree = new JTree(treeModel);
+
+ private PaletteCatalog catalog;
+ private PlayerLocation location;
+ private String selectedBiome;
+ private int selectedLayer;
+ private boolean updatingSelection;
+
+ PaletteNavigator(boolean followsPlayer, Listener listener) {
+ super(new BorderLayout());
+ this.followsPlayer = followsPlayer;
+ this.listener = listener;
+ setBackground(SIDEBAR_BACKGROUND);
+ cardPanel.add(buildHereCard(), HERE_CARD);
+ cardPanel.add(buildOverviewCard(), OVERVIEW_CARD);
+ add(buildTabBar(), BorderLayout.NORTH);
+ add(cardPanel, BorderLayout.CENTER);
+ refreshHere();
+ }
+
+ void showCatalog(PaletteCatalog nextCatalog) {
+ catalog = nextCatalog;
+ rebuildOverview();
+ refreshHere();
+ }
+
+ void showLocation(PlayerLocation nextLocation) {
+ String previousBiome = location == null ? null : location.biomeKey();
+ location = nextLocation;
+ refreshHere();
+ boolean enteredNewBiome = nextLocation.biomeKey() != null && !nextLocation.biomeKey().equals(previousBiome);
+ if (enteredNewBiome && followCheckBox.isSelected()) {
+ chooseBiome(nextLocation.biomeKey(), selectedLayer);
+ }
+ }
+
+ void markSelected(String biomeKey, int layer) {
+ selectedBiome = biomeKey;
+ selectedLayer = layer;
+ updatingSelection = true;
+ try {
+ selectInHereList();
+ selectInTree();
+ } finally {
+ updatingSelection = false;
+ }
+ }
+
+ private JPanel buildTabBar() {
+ JPanel bar = new JPanel(new GridLayout(1, 2, 6, 0));
+ bar.setBackground(SIDEBAR_BACKGROUND);
+ bar.setBorder(BorderFactory.createEmptyBorder(0, 12, 8, 12));
+ ButtonGroup group = new ButtonGroup();
+ JToggleButton here = createTab("Here", HERE_CARD, group);
+ JToggleButton overview = createTab("Overview", OVERVIEW_CARD, group);
+ bar.add(here);
+ bar.add(overview);
+ (followsPlayer ? here : overview).doClick();
+ return bar;
+ }
+
+ private JToggleButton createTab(String text, String card, ButtonGroup group) {
+ JToggleButton tab = new JToggleButton(text);
+ tab.setFocusPainted(false);
+ tab.setFont(ITEM_FONT);
+ tab.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(BORDER),
+ BorderFactory.createEmptyBorder(5, 9, 5, 9)
+ ));
+ tab.addItemListener(event -> styleTab(tab));
+ tab.addActionListener(event -> cards.show(cardPanel, card));
+ styleTab(tab);
+ group.add(tab);
+ return tab;
+ }
+
+ private static void styleTab(JToggleButton tab) {
+ tab.setBackground(tab.isSelected() ? ACCENT.darker().darker() : FIELD_BACKGROUND);
+ tab.setForeground(tab.isSelected() ? PRIMARY_TEXT : SECONDARY_TEXT);
+ tab.setContentAreaFilled(false);
+ tab.setOpaque(true);
+ }
+
+ private JPanel buildHereCard() {
+ JPanel card = new JPanel(new BorderLayout());
+ card.setBackground(SIDEBAR_BACKGROUND);
+ JPanel location = new JPanel(new GridLayout(0, 1, 0, 3));
+ location.setBackground(PANEL_BACKGROUND);
+ location.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
+ BorderFactory.createEmptyBorder(9, 12, 9, 12)
+ ));
+ for (JLabel label : List.of(regionLabel, biomeLabel)) {
+ label.setForeground(PRIMARY_TEXT);
+ label.setFont(ITEM_FONT);
+ location.add(label);
+ }
+ followCheckBox.setOpaque(false);
+ followCheckBox.setForeground(SECONDARY_TEXT);
+ followCheckBox.setFont(ITEM_FONT);
+ followCheckBox.setToolTipText("Open the biome you walk into");
+ followCheckBox.setVisible(followsPlayer);
+ location.add(followCheckBox);
+
+ hereList.setBackground(SIDEBAR_BACKGROUND);
+ hereList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+ hereList.setCellRenderer(new HereRowRenderer());
+ hereList.addListSelectionListener(event -> {
+ HereRow row = hereList.getSelectedValue();
+ if (!event.getValueIsAdjusting() && !updatingSelection && row != null && row.biomeKey() != null) {
+ chooseBiome(row.biomeKey(), Math.max(0, row.layer()));
+ }
+ });
+ card.add(location, BorderLayout.NORTH);
+ card.add(scroll(hereList), BorderLayout.CENTER);
+ return card;
+ }
+
+ private JPanel buildOverviewCard() {
+ JPanel card = new JPanel(new BorderLayout());
+ card.setBackground(SIDEBAR_BACKGROUND);
+ searchField.setBackground(FIELD_BACKGROUND);
+ searchField.setForeground(PRIMARY_TEXT);
+ searchField.setCaretColor(PRIMARY_TEXT);
+ searchField.setFont(ITEM_FONT);
+ searchField.putClientProperty("JTextField.placeholderText", "Search biomes or blocks...");
+ searchField.setToolTipText("Search biomes or blocks");
+ searchField.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
+ BorderFactory.createEmptyBorder(8, 12, 8, 12)
+ ));
+ searchField.getDocument().addDocumentListener(new DocumentListener() {
+ @Override
+ public void insertUpdate(DocumentEvent event) {
+ rebuildOverview();
+ }
+
+ @Override
+ public void removeUpdate(DocumentEvent event) {
+ rebuildOverview();
+ }
+
+ @Override
+ public void changedUpdate(DocumentEvent event) {
+ rebuildOverview();
+ }
+ });
+
+ overviewTree.setRootVisible(false);
+ overviewTree.setShowsRootHandles(true);
+ overviewTree.setBackground(SIDEBAR_BACKGROUND);
+ overviewTree.setRowHeight(22);
+ overviewTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
+ overviewTree.setCellRenderer(new OverviewRenderer());
+ overviewTree.addTreeSelectionListener(event -> {
+ if (!updatingSelection && nodeAt(event.getPath()) instanceof OverviewNode node && node.biomeKey() != null) {
+ chooseBiome(node.biomeKey(), Math.max(0, node.layer()));
+ }
+ });
+ card.add(searchField, BorderLayout.NORTH);
+ card.add(scroll(overviewTree), BorderLayout.CENTER);
+ return card;
+ }
+
+ private static JScrollPane scroll(Component content) {
+ JScrollPane scrollPane = new JScrollPane(content);
+ scrollPane.setBorder(BorderFactory.createEmptyBorder());
+ scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
+ scrollPane.getViewport().setBackground(SIDEBAR_BACKGROUND);
+ scrollPane.getVerticalScrollBar().setUnitIncrement(18);
+ return scrollPane;
+ }
+
+ private void chooseBiome(String biomeKey, int preferredLayer) {
+ BiomeEntry biome = catalog == null ? null : catalog.biome(biomeKey);
+ if (biome == null || biome.layerSummaries().isEmpty()) {
+ return;
+ }
+ listener.onLayerChosen(biomeKey, Math.min(preferredLayer, biome.layerSummaries().size() - 1));
+ }
+
+ private void refreshHere() {
+ updatingSelection = true;
+ try {
+ hereRows.clear();
+ if (!followsPlayer) {
+ showHereMessage("Run /iris std pal in-game", "to see where you are standing.");
+ return;
+ }
+ if (location == null || catalog == null) {
+ showHereMessage("Finding where you are...", " ");
+ return;
+ }
+ switch (location.status()) {
+ case OFFLINE -> showHereMessage("You are offline.", " ");
+ case OUTSIDE_IRIS_WORLD -> showHereMessage("You are not in an Iris world.", " ");
+ case IN_BIOME -> showHereRows(location.regionKey(), location.biomeKey());
+ }
+ selectInHereList();
+ } finally {
+ updatingSelection = false;
+ }
+ }
+
+ private void showHereMessage(String firstLine, String secondLine) {
+ regionLabel.setText(firstLine);
+ biomeLabel.setText(secondLine);
+ }
+
+ private void showHereRows(String regionKey, String biomeKey) {
+ RegionEntry region = catalog.region(regionKey);
+ BiomeEntry biome = catalog.biome(biomeKey);
+ regionLabel.setText(labeled("Region", region == null ? regionKey : region.name(), regionKey));
+ biomeLabel.setText(labeled("Biome", biome == null ? biomeKey : biome.name(), biomeKey));
+ if (biome != null) {
+ hereRows.addElement(HereRow.header("Layers here"));
+ for (int layer = 0; layer < biome.layerSummaries().size(); layer++) {
+ hereRows.addElement(new HereRow(layerText(layer, biome), biomeKey, layer, false));
+ }
+ if (biome.layerSummaries().isEmpty()) {
+ hereRows.addElement(HereRow.header("This biome has no palette layers"));
+ }
+ }
+ if (region == null) {
+ return;
+ }
+ hereRows.addElement(HereRow.header("Other biomes in this region"));
+ for (String otherKey : region.allBiomeKeys()) {
+ BiomeEntry other = catalog.biome(otherKey);
+ if (!otherKey.equals(biomeKey) && other != null) {
+ hereRows.addElement(new HereRow(biomeText(other), otherKey, -1, false));
+ }
+ }
+ }
+
+ private void rebuildOverview() {
+ if (catalog == null) {
+ return;
+ }
+ String filter = searchField.getText().trim().toLowerCase(Locale.ROOT);
+ DefaultMutableTreeNode root = new DefaultMutableTreeNode();
+ for (RegionEntry region : catalog.regions()) {
+ boolean regionMatches = !filter.isEmpty() && region.name().toLowerCase(Locale.ROOT).contains(filter);
+ DefaultMutableTreeNode regionNode = new DefaultMutableTreeNode(new OverviewNode(region.name(), region.key(), null, -1));
+ for (BiomeGroup group : region.groups()) {
+ DefaultMutableTreeNode groupNode = new DefaultMutableTreeNode();
+ for (String biomeKey : group.biomeKeys()) {
+ addBiomeNode(groupNode, biomeKey, regionMatches ? "" : filter, new HashSet<>());
+ }
+ if (groupNode.getChildCount() > 0) {
+ groupNode.setUserObject(new OverviewNode(group.label(), groupNode.getChildCount() + " biomes", null, -1));
+ regionNode.add(groupNode);
+ }
+ }
+ if (regionNode.getChildCount() > 0) {
+ root.add(regionNode);
+ }
+ }
+ DefaultMutableTreeNode outside = new DefaultMutableTreeNode();
+ for (String biomeKey : catalog.biomesOutsideRegions()) {
+ addBiomeNode(outside, biomeKey, filter, new HashSet<>());
+ }
+ if (outside.getChildCount() > 0) {
+ outside.setUserObject(new OverviewNode("Not in any region", outside.getChildCount() + " biomes", null, -1));
+ root.add(outside);
+ }
+ updatingSelection = true;
+ try {
+ treeModel.setRoot(root);
+ if (!filter.isEmpty()) {
+ expandDownToBiomes(root);
+ }
+ selectInTree();
+ } finally {
+ updatingSelection = false;
+ }
+ }
+
+ private void addBiomeNode(DefaultMutableTreeNode parent, String biomeKey, String filter, Set ancestors) {
+ BiomeEntry biome = catalog.biome(biomeKey);
+ if (biome == null || !ancestors.add(biomeKey)) {
+ return;
+ }
+ DefaultMutableTreeNode biomeNode = new DefaultMutableTreeNode(new OverviewNode(biome.name(), biomeKey, biomeKey, -1));
+ for (int layer = 0; layer < biome.layerSummaries().size(); layer++) {
+ biomeNode.add(new DefaultMutableTreeNode(new OverviewNode(layerText(layer, biome), null, biomeKey, layer)));
+ }
+ for (String childKey : biome.childKeys()) {
+ addBiomeNode(biomeNode, childKey, filter, new HashSet<>(ancestors));
+ }
+ boolean hasMatchingChild = biomeNode.getChildCount() > biome.layerSummaries().size();
+ if (filter.isEmpty() || biome.matches(filter) || hasMatchingChild) {
+ parent.add(biomeNode);
+ }
+ }
+
+ private void expandDownToBiomes(DefaultMutableTreeNode root) {
+ Enumeration> nodes = root.breadthFirstEnumeration();
+ while (nodes.hasMoreElements()) {
+ DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
+ if (node.getUserObject() instanceof OverviewNode overview && overview.biomeKey() != null) {
+ overviewTree.expandPath(new TreePath(((DefaultMutableTreeNode) node.getParent()).getPath()));
+ }
+ }
+ }
+
+ private void selectInTree() {
+ DefaultMutableTreeNode match = findLayerNode((DefaultMutableTreeNode) treeModel.getRoot());
+ if (match == null) {
+ overviewTree.clearSelection();
+ return;
+ }
+ TreePath path = new TreePath(match.getPath());
+ overviewTree.setSelectionPath(path);
+ overviewTree.makeVisible(path);
+ Rectangle row = overviewTree.getPathBounds(path);
+ if (row != null) {
+ overviewTree.scrollRectToVisible(new Rectangle(0, row.y, 1, row.height));
+ }
+ }
+
+ private DefaultMutableTreeNode findLayerNode(DefaultMutableTreeNode root) {
+ Enumeration> nodes = root.depthFirstEnumeration();
+ while (nodes.hasMoreElements()) {
+ DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
+ if (node.getUserObject() instanceof OverviewNode overview
+ && overview.layer() == selectedLayer
+ && overview.biomeKey() != null
+ && overview.biomeKey().equals(selectedBiome)) {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ private void selectInHereList() {
+ for (int index = 0; index < hereRows.size(); index++) {
+ HereRow row = hereRows.get(index);
+ if (row.layer() == selectedLayer && row.biomeKey() != null && row.biomeKey().equals(selectedBiome)) {
+ hereList.setSelectedIndex(index);
+ return;
+ }
+ }
+ hereList.clearSelection();
+ }
+
+ private static Object nodeAt(TreePath path) {
+ return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
+ }
+
+ private static String layerText(int layer, BiomeEntry biome) {
+ return "Layer " + layer + " · " + biome.layerSummaries().get(layer);
+ }
+
+ private static String biomeText(BiomeEntry biome) {
+ return "" + escape(biome.name()) + " " + escape(biome.key()) + "";
+ }
+
+ private static String labeled(String title, String name, String key) {
+ return "" + title + " " + escape(name)
+ + " " + escape(key) + "";
+ }
+
+ private static String escape(String text) {
+ return text.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+
+ interface Listener {
+ void onLayerChosen(String biomeKey, int layer);
+ }
+
+ private record HereRow(String text, String biomeKey, int layer, boolean header) {
+ private static HereRow header(String text) {
+ return new HereRow(text, null, -1, true);
+ }
+ }
+
+ private record OverviewNode(String label, String detail, String biomeKey, int layer) {
+ private String html() {
+ if (detail == null) {
+ return escape(label);
+ }
+ return "" + escape(label) + " " + escape(detail) + "";
+ }
+ }
+
+ private static final class HereRowRenderer extends DefaultListCellRenderer {
+ private static final long serialVersionUID = 2785341117408251320L;
+
+ @Override
+ public Component getListCellRendererComponent(JList> list, Object value, int index, boolean selected, boolean focus) {
+ HereRow row = (HereRow) value;
+ boolean selectable = row.biomeKey() != null;
+ super.getListCellRendererComponent(list, row.text(), index, selected && selectable, false);
+ setOpaque(true);
+ setFont(row.header() ? HEADER_FONT : ITEM_FONT);
+ setForeground(row.header() ? ACCENT : selected ? PRIMARY_TEXT : SECONDARY_TEXT);
+ setBackground(selected && selectable ? FIELD_BACKGROUND.brighter() : SIDEBAR_BACKGROUND);
+ int indent = row.header() ? 11 : 20;
+ setBorder(BorderFactory.createEmptyBorder(row.header() ? 11 : 4, indent, 4, 10));
+ return this;
+ }
+ }
+
+ private static final class OverviewRenderer extends DefaultTreeCellRenderer {
+ private static final long serialVersionUID = -1950176034017718227L;
+
+ private OverviewRenderer() {
+ setLeafIcon(null);
+ setOpenIcon(null);
+ setClosedIcon(null);
+ setBackgroundNonSelectionColor(SIDEBAR_BACKGROUND);
+ setBackgroundSelectionColor(FIELD_BACKGROUND.brighter());
+ setBorderSelectionColor(null);
+ setTextNonSelectionColor(SECONDARY_TEXT);
+ setTextSelectionColor(PRIMARY_TEXT);
+ }
+
+ @Override
+ public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
+ boolean leaf, int row, boolean focus) {
+ Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
+ String text = userObject instanceof OverviewNode node ? node.html() : "";
+ super.getTreeCellRendererComponent(tree, text, selected, expanded, leaf, row, false);
+ setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? HEADER_FONT : ITEM_FONT);
+ return this;
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/PlayerLocationTracker.java b/core/src/main/java/com/volmit/iris/core/gui/PlayerLocationTracker.java
new file mode 100644
index 000000000..b240c5c1c
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/PlayerLocationTracker.java
@@ -0,0 +1,108 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.Iris;
+import com.volmit.iris.core.tools.IrisToolbelt;
+import com.volmit.iris.engine.framework.Engine;
+import com.volmit.iris.engine.object.IrisBiome;
+import com.volmit.iris.engine.object.IrisRegion;
+import com.volmit.iris.engine.platform.PlatformChunkGenerator;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.entity.Player;
+import org.bukkit.scheduler.BukkitTask;
+
+import java.awt.EventQueue;
+import java.util.UUID;
+
+final class PlayerLocationTracker {
+ private static final long CHECK_INTERVAL_TICKS = 10L;
+
+ private final UUID playerId;
+ private final Listener listener;
+ private BukkitTask task;
+ private PlayerLocation lastLocation;
+
+ private PlayerLocationTracker(UUID playerId, Listener listener) {
+ this.playerId = playerId;
+ this.listener = listener;
+ }
+
+ static PlayerLocationTracker follow(UUID playerId, Listener listener) {
+ PlayerLocationTracker tracker = new PlayerLocationTracker(playerId, listener);
+ tracker.task = Bukkit.getScheduler().runTaskTimer(Iris.instance, tracker::checkLocation, 0L, CHECK_INTERVAL_TICKS);
+ return tracker;
+ }
+
+ void stop() {
+ if (task != null) {
+ task.cancel();
+ }
+ }
+
+ private void checkLocation() {
+ PlayerLocation location = locate();
+ if (location.equals(lastLocation)) {
+ return;
+ }
+ lastLocation = location;
+ EventQueue.invokeLater(() -> listener.onLocationChanged(location));
+ }
+
+ private PlayerLocation locate() {
+ Player player = Bukkit.getPlayer(playerId);
+ if (player == null) {
+ return PlayerLocation.OFFLINE;
+ }
+ Engine engine = engineAt(player.getLocation());
+ if (engine == null) {
+ return PlayerLocation.OUTSIDE_IRIS_WORLD;
+ }
+ IrisRegion region = engine.getRegion(player.getLocation());
+ IrisBiome biome = engine.getBiomeOrMantle(player.getLocation());
+ if (region == null || biome == null) {
+ return PlayerLocation.OUTSIDE_IRIS_WORLD;
+ }
+ return new PlayerLocation(Status.IN_BIOME, region.getLoadKey(), biome.getLoadKey());
+ }
+
+ private static Engine engineAt(Location location) {
+ if (location.getWorld() == null || !IrisToolbelt.isIrisWorld(location.getWorld())) {
+ return null;
+ }
+ PlatformChunkGenerator generator = IrisToolbelt.access(location.getWorld());
+ return generator == null ? null : generator.getEngine();
+ }
+
+ enum Status {
+ OFFLINE,
+ OUTSIDE_IRIS_WORLD,
+ IN_BIOME
+ }
+
+ record PlayerLocation(Status status, String regionKey, String biomeKey) {
+ static final PlayerLocation OFFLINE = new PlayerLocation(Status.OFFLINE, null, null);
+ static final PlayerLocation OUTSIDE_IRIS_WORLD = new PlayerLocation(Status.OUTSIDE_IRIS_WORLD, null, null);
+ }
+
+ interface Listener {
+ void onLocationChanged(PlayerLocation location);
+ }
+}