From 252facf41e043e41d6c9c741cd77791bd64894aa Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 18 Oct 2026 20:33:32 +0200 Subject: [PATCH] Show real block textures and a region overview in the palette explorer Block textures come from the Minecraft client jar, downloaded once from Mojang and cached under plugins/Iris/cache. Each block shows its tinted top face: texture pixels when zoomed in, the texture's average colour when zoomed out, and the map colour when no texture is available. The sidebar now has two tabs. Here follows the player who opened it and shows their region, the biome they stand in with its layers, and the region's other biomes, opening each new biome they walk into. Overview is a searchable tree of region, biome group, biome and layer. Model used: Claude Opus 5.5 --- .../iris/core/commands/CommandStudio.java | 2 +- .../volmit/iris/core/gui/BlockTextures.java | 381 +++++++++++++ .../iris/core/gui/MinecraftClientJar.java | 145 +++++ .../volmit/iris/core/gui/NoiseColorScale.java | 4 + .../iris/core/gui/NoiseExplorerGUI.java | 117 ++-- .../iris/core/gui/NoiseRenderCoordinator.java | 2 +- .../volmit/iris/core/gui/PaletteCatalog.java | 134 +++++ .../iris/core/gui/PaletteInspectorPanel.java | 34 +- .../iris/core/gui/PaletteLayerPreview.java | 53 +- .../iris/core/gui/PaletteLayerScale.java | 14 +- .../iris/core/gui/PaletteNavigator.java | 513 ++++++++++++++++++ .../iris/core/gui/PlayerLocationTracker.java | 108 ++++ 12 files changed, 1447 insertions(+), 60 deletions(-) create mode 100644 core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/MinecraftClientJar.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/PlayerLocationTracker.java 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); + } +}