mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-20 07:24:16 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<BlockTextures> shared;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final Map<String, Optional<BlockTexture>> topTextures = new ConcurrentHashMap<>();
|
||||
|
||||
private BlockTextures(ZipFile clientJar) {
|
||||
this.clientJar = clientJar;
|
||||
}
|
||||
|
||||
static CompletableFuture<BlockTextures> shared() {
|
||||
synchronized (SHARED_LOCK) {
|
||||
if (shared == null || shared.isCompletedExceptionally()) {
|
||||
shared = MinecraftClientJar.open().thenApply(BlockTextures::new);
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<BlockTexture> topTexture(BlockData block) {
|
||||
return topTextures.computeIfAbsent(block.getAsString(true), this::resolveTopTexture);
|
||||
}
|
||||
|
||||
private Optional<BlockTexture> 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<String, String> 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<String, JsonElement> 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<String, JsonElement> 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<String, String> 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<String, String> properties) {
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> 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<String, String> 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<String, String> 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<String, JsonElement> 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<String, String> 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<String, String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<ZipFile> 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<InputStream> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<NoisePalette> 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<SourceItem> 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<SourceItem> 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<SourceItem> loadPackPaletteSources(Engine engine) {
|
||||
private List<SourceItem> 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<String> 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<SourceItem> 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<SourceItem> 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<SourceItem> 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<SourceItem> matchingSources(NoiseCategory category, String filter) {
|
||||
List<SourceItem> 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<RegionEntry> regions, List<String> biomesOutsideRegions, Map<String, BiomeEntry> biomes) {
|
||||
private static final int SUMMARIZED_BLOCK_COUNT = 3;
|
||||
|
||||
static PaletteCatalog load(Engine engine) {
|
||||
IrisData data = engine.getData();
|
||||
Map<String, BiomeEntry> biomes = loadBiomes(data);
|
||||
List<RegionEntry> regions = new ArrayList<>();
|
||||
Set<String> 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<String> 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<String> 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<String, BiomeEntry> loadBiomes(IrisData data) {
|
||||
String[] keys = data.getBiomeLoader().getPossibleKeys();
|
||||
Map<String, BiomeEntry> 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<String> 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<String, BiomeEntry> biomes) {
|
||||
List<BiomeGroup> 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<BiomeGroup> groups, String label, List<String> keys, Map<String, BiomeEntry> biomes) {
|
||||
List<String> known = keys.stream().filter(biomes::containsKey).distinct().toList();
|
||||
if (!known.isEmpty()) {
|
||||
groups.add(new BiomeGroup(label, known));
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectWithChildren(String key, Map<String, BiomeEntry> biomes, Set<String> 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<BiomeGroup> groups) {
|
||||
List<String> allBiomeKeys() {
|
||||
return groups.stream().flatMap(group -> group.biomeKeys().stream()).distinct().toList();
|
||||
}
|
||||
}
|
||||
|
||||
record BiomeGroup(String label, List<String> biomeKeys) {
|
||||
}
|
||||
|
||||
record BiomeEntry(String key, String name, List<String> layerSummaries, List<String> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> notes(PaletteLayerPreview preview) {
|
||||
private List<String> notes() {
|
||||
List<String> 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,
|
||||
|
||||
@@ -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<Entry> 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<IrisBlockData> 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<IrisBlockData> palette = paletteLayer.getPalette();
|
||||
List<BlockData> blocks = new ArrayList<>();
|
||||
for (IrisBlockData entry : palette) {
|
||||
BlockData block = entry.getBlockData(data);
|
||||
if (block != null) {
|
||||
blocks.add(block);
|
||||
List<BlockData> 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<String, Integer> colorsByBlock = BlockPreviewColors.colorsByBlock(blocks);
|
||||
Map<String, Integer> 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<HereRow> hereRows = new DefaultListModel<>();
|
||||
private final JList<HereRow> 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<String> 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 "<html>" + escape(biome.name()) + " <font color='" + DETAIL_COLOR + "'>" + escape(biome.key()) + "</font></html>";
|
||||
}
|
||||
|
||||
private static String labeled(String title, String name, String key) {
|
||||
return "<html><font color='" + DETAIL_COLOR + "'>" + title + "</font> " + escape(name)
|
||||
+ " <font color='" + DETAIL_COLOR + "'>" + escape(key) + "</font></html>";
|
||||
}
|
||||
|
||||
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 "<html>" + escape(label) + " <font color='" + DETAIL_COLOR + "'>" + escape(detail) + "</font></html>";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user