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); + } +}