mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 08:07:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ba82c6fb | ||
|
|
86ef09e768 | ||
|
|
97612385e5 | ||
|
|
3123ab3753 | ||
|
|
4ac268047e | ||
|
|
0b5b75e936 | ||
|
|
97dfb63741 | ||
|
|
29942fa1c2 | ||
|
|
ea42141550 | ||
|
|
9eb56543a3 | ||
|
|
93f5615f00 | ||
|
|
f30f761ae6 | ||
|
|
03fd5e4bf3 | ||
|
|
2659837007 | ||
|
|
9d7211378d | ||
|
|
dc7615219b | ||
|
|
8d12019246 | ||
|
|
26825869e9 | ||
|
|
59626d2434 | ||
|
|
46f5472081 | ||
|
|
2cd9167549 | ||
|
|
a648ef6622 | ||
|
|
51c9d01bf7 | ||
|
|
99258bfd98 | ||
|
|
db3d42df26 | ||
|
|
b97bd51f20 | ||
|
|
a02b4386a4 | ||
|
|
5a99091193 | ||
|
|
88c2688662 | ||
|
|
f73c78349b | ||
|
|
0a76208194 | ||
|
|
38b340ac14 | ||
|
|
bfeb017037 | ||
|
|
89b864a142 | ||
|
|
a49a4e0949 | ||
|
|
3c773e3e57 | ||
|
|
84ffce263a | ||
|
|
e4675ecab8 | ||
|
|
b24c38896d | ||
|
|
6270b62552 | ||
|
|
8a1c7ac998 | ||
|
|
8c39f9a01b | ||
|
|
74756dd19e | ||
|
|
5363cbbc02 | ||
|
|
19bbca0e4b | ||
|
|
ec0d2d816a | ||
|
|
340e66105c | ||
|
|
e58d065ca4 | ||
|
|
cebd582018 | ||
|
|
f44ce5b327 | ||
|
|
b1445430bb | ||
|
|
80e7e37791 | ||
|
|
8e71c14f65 | ||
|
|
eb1a244114 | ||
|
|
05eed1f6a5 | ||
|
|
6c3822cdaf | ||
|
|
13312e9182 | ||
|
|
674502eeda | ||
|
|
143ba4771b | ||
|
|
02956fd358 | ||
|
|
76860e94fa | ||
|
|
252facf41e | ||
|
|
ad88b00c2a | ||
|
|
6f930d1370 | ||
|
|
855ab90be4 | ||
|
|
8783fa09a5 | ||
|
|
87dc78934f | ||
|
|
d35fe93f7f | ||
|
|
d6d6fd031c | ||
|
|
7f34bb917f | ||
|
|
18e0834db8 | ||
|
|
6a85cb53a9 | ||
|
|
8a0fd9b9fc | ||
|
|
2fc4c2faf9 | ||
|
|
475f72a300 | ||
|
|
bc7a530486 | ||
|
|
b12b1ee304 | ||
|
|
196de7d858 | ||
|
|
8a18b6ff8b | ||
|
|
e1514503f6 | ||
|
|
f1c1869e43 |
No files matched your search
+10
-6
@@ -34,7 +34,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.volmit"
|
||||
version = "3.9.2-1.20.1-1.21.11"
|
||||
version = "3.9.2-1.20.1-26.3"
|
||||
|
||||
apply<ApiGenerator>()
|
||||
|
||||
@@ -64,6 +64,8 @@ val color = "truecolor"
|
||||
val errorReporting = "true" == findProperty("errorReporting")
|
||||
|
||||
val nmsBindings = mapOf(
|
||||
"v26_3_R1" to "26.3.build.176-stable",
|
||||
"v26_1_R1" to "26.1.2.build.74-stable",
|
||||
"v1_21_R7" to "1.21.11-R0.1-SNAPSHOT",
|
||||
"v1_21_R6" to "1.21.10-R0.1-SNAPSHOT",
|
||||
"v1_21_R5" to "1.21.8-R0.1-SNAPSHOT",
|
||||
@@ -76,7 +78,9 @@ val nmsBindings = mapOf(
|
||||
"v1_20_R2" to "1.20.2-R0.1-SNAPSHOT",
|
||||
"v1_20_R1" to "1.20.1-R0.1-SNAPSHOT",
|
||||
)
|
||||
val jvmVersion = mapOf<String, Int>()
|
||||
val jvmVersion = mapOf("v26_3_R1" to 25, "v26_1_R1" to 25)
|
||||
// From 26.1 on the server is not obfuscated, so these bindings compile against Paper's dev bundle as-is.
|
||||
val userDevBindings = setOf("v26_3_R1", "v26_1_R1")
|
||||
nmsBindings.forEach { (key, value) ->
|
||||
project(":nms:$key") {
|
||||
apply<JavaPlugin>()
|
||||
@@ -84,7 +88,7 @@ nmsBindings.forEach { (key, value) ->
|
||||
nmsBinding {
|
||||
jvm = jvmVersion.getOrDefault(key, 21)
|
||||
version = value
|
||||
type = NMSBinding.Type.DIRECT
|
||||
type = if (key in userDevBindings) NMSBinding.Type.USER_DEV else NMSBinding.Type.DIRECT
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -96,7 +100,7 @@ nmsBindings.forEach { (key, value) ->
|
||||
|
||||
tasks.register<RunServer>("runServer-$key") {
|
||||
group = "servers"
|
||||
minecraftVersion(value.split("-")[0])
|
||||
minecraftVersion(value.substringBefore("-").substringBefore(".build"))
|
||||
minHeapSize = serverMinHeap
|
||||
maxHeapSize = serverMaxHeap
|
||||
pluginJars(tasks.jar.flatMap { it.archiveFile })
|
||||
@@ -115,7 +119,7 @@ val included: Configuration by configurations.creating
|
||||
val jarJar: Configuration by configurations.creating
|
||||
dependencies {
|
||||
for (key in nmsBindings.keys) {
|
||||
included(project(":nms:$key", "reobf"))
|
||||
included(project(":nms:$key", if (key in userDevBindings) "runtimeElements" else "reobf"))
|
||||
}
|
||||
included(project(":core", "shadow"))
|
||||
jarJar(project(":core:agent"))
|
||||
@@ -190,7 +194,7 @@ allprojects {
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
languageVersion.set(JavaLanguageVersion.of(jvmVersion.getOrDefault(name, 21)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,6 @@ repositories {
|
||||
dependencies {
|
||||
implementation("org.ow2.asm:asm:9.8")
|
||||
implementation("com.github.VolmitSoftware:NMSTools:c88961416f")
|
||||
implementation("io.papermc.paperweight:paperweight-userdev:2.0.0-beta.18")
|
||||
implementation("io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
}
|
||||
@@ -50,18 +50,7 @@ class NMSBinding : Plugin<Project> {
|
||||
it.version.set(config.version)
|
||||
}
|
||||
|
||||
configurations.register(REOBF_CONFIG) { conf ->
|
||||
conf.isCanBeConsumed = true
|
||||
conf.isCanBeResolved = false
|
||||
conf.attributes {
|
||||
it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
|
||||
it.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
|
||||
it.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR))
|
||||
it.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL))
|
||||
it.attribute(Obfuscation.OBFUSCATION_ATTRIBUTE, objects.named(Obfuscation.OBFUSCATED))
|
||||
}
|
||||
conf.outgoing.artifact(tasks.named("remap"))
|
||||
}
|
||||
registerReobfConfiguration("remap")
|
||||
}
|
||||
|
||||
val (major, minor) = config.version.parseVersion()
|
||||
@@ -71,6 +60,20 @@ class NMSBinding : Plugin<Project> {
|
||||
rootProject.tasks.named("prepareKotlinBuildScriptModel") { it.dependsOn("$path:convert") }
|
||||
}
|
||||
|
||||
private fun Project.registerReobfConfiguration(artifactTask: String) {
|
||||
val conf = configurations.create(REOBF_CONFIG)
|
||||
conf.isCanBeConsumed = true
|
||||
conf.isCanBeResolved = false
|
||||
conf.attributes {
|
||||
it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
|
||||
it.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
|
||||
it.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR))
|
||||
it.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL))
|
||||
it.attribute(Obfuscation.OBFUSCATION_ATTRIBUTE, objects.named(Obfuscation.OBFUSCATED))
|
||||
}
|
||||
conf.outgoing.artifact(tasks.named(artifactTask))
|
||||
}
|
||||
|
||||
@DisableCachingByDefault
|
||||
abstract class ConversionTask @Inject constructor(type: Type) : DefaultTask() {
|
||||
private val pattern: Regex
|
||||
@@ -163,8 +166,10 @@ class NMSBinding : Plugin<Project> {
|
||||
|
||||
private val NEW_LINE = System.lineSeparator()
|
||||
private val NEW_LINE_BYTES = NEW_LINE.encodeToByteArray()
|
||||
// "1.21.11-R0.1-SNAPSHOT" -> (21, 11), "26.1.2.build.74-stable" -> (26, 1)
|
||||
private fun String.parseVersion() = substringBefore('-').split(".").let {
|
||||
it[1].toInt() to it[2].toInt()
|
||||
if (it[0] == "1") it[1].toInt() to it[2].toInt()
|
||||
else it[0].toInt() to it[1].toInt()
|
||||
}
|
||||
|
||||
class Config(
|
||||
|
||||
@@ -52,7 +52,8 @@ val lib = "com.volmit.iris.util"
|
||||
*/
|
||||
dependencies {
|
||||
// Provided or Classpath
|
||||
compileOnly(libs.spigot)
|
||||
compileOnly(libs.paper)
|
||||
compileOnly(platform(libs.adventure.bom))
|
||||
compileOnly(libs.log4j.api)
|
||||
compileOnly(libs.log4j.core)
|
||||
|
||||
@@ -78,10 +79,6 @@ dependencies {
|
||||
|
||||
// Dynamically Loaded
|
||||
slim(libs.paralithic)
|
||||
slim(libs.paperlib)
|
||||
slim(libs.adventure.api)
|
||||
slim(libs.adventure.minimessage)
|
||||
slim(libs.adventure.platform)
|
||||
slim(libs.bstats)
|
||||
slim(libs.sentry)
|
||||
|
||||
@@ -101,6 +98,8 @@ dependencies {
|
||||
slim(libs.byteBuddy.agent)
|
||||
slim(libs.dom4j)
|
||||
slim(libs.jaxen)
|
||||
slim(libs.flatlaf)
|
||||
slim(libs.flatlaf.extras)
|
||||
|
||||
// Script Engine
|
||||
slim(libs.kotlin.stdlib)
|
||||
@@ -146,8 +145,6 @@ slimJar {
|
||||
))
|
||||
|
||||
relocate("com.dfsek.paralithic", "$lib.paralithic")
|
||||
relocate("io.papermc.lib", "$lib.paper")
|
||||
relocate("net.kyori", "$lib.kyori")
|
||||
relocate("org.bstats", "$lib.metrics")
|
||||
relocate("io.sentry", "$lib.sentry")
|
||||
relocate("org.apache.maven", "$lib.maven")
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.pregenerator.LazyPregenerator;
|
||||
import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.EnginePanic;
|
||||
import com.volmit.iris.engine.object.IrisCompat;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
@@ -86,13 +87,14 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
private static final Queue<Runnable> syncJobs = new ShurikenQueue<>();
|
||||
|
||||
public static Iris instance;
|
||||
public static Bindings.Adventure audiences;
|
||||
public static MultiverseCoreLink linkMultiverseCore;
|
||||
public static IrisCompat compat;
|
||||
public static FileWatcher configWatcher;
|
||||
public static ChunkTickets tickets;
|
||||
private static VolmitSender sender;
|
||||
private static Thread shutdownHook;
|
||||
private static final String STUDIO_WORLDS_FOLDER = "iris";
|
||||
private static final String NMS_BINDINGS_PACKAGE = "com.volmit.iris.core.nms.v";
|
||||
|
||||
static {
|
||||
try {
|
||||
@@ -435,11 +437,11 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
|
||||
private void enable() {
|
||||
services = new KMap<>();
|
||||
setupAudience();
|
||||
Bindings.setupSentry();
|
||||
initialize("com.volmit.iris.core.service").forEach((i) -> services.put((Class<? extends IrisService>) i.getClass(), (IrisService) i));
|
||||
IO.delete(new File("iris"));
|
||||
IO.delete(WorldStorage.get().worldFolder(STUDIO_WORLDS_FOLDER));
|
||||
compat = IrisCompat.configured(getDataFile("compat.json"));
|
||||
adoptLegacyWorldData();
|
||||
ServerConfigurator.configure();
|
||||
IrisSafeguard.execute();
|
||||
getSender().setTag(getTag());
|
||||
@@ -466,6 +468,19 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
});
|
||||
}
|
||||
|
||||
private void adoptLegacyWorldData() {
|
||||
WorldStorage storage = WorldStorage.get();
|
||||
for (String worldName : IrisWorlds.readKnownWorldNames()) {
|
||||
try {
|
||||
storage.adoptLegacyData(worldName);
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to move the Iris data of " + worldName + " to its new world folder!");
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void addShutdownHook() {
|
||||
if (shutdownHook != null) {
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
@@ -532,36 +547,47 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupAudience() {
|
||||
try {
|
||||
audiences = new Bindings.Adventure(this);
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
IrisSettings.get().getGeneral().setUseConsoleCustomColors(false);
|
||||
IrisSettings.get().getGeneral().setUseCustomColorsIngame(false);
|
||||
Iris.error("Failed to setup Adventure API... No custom colors :(");
|
||||
}
|
||||
}
|
||||
|
||||
public void postShutdown(Runnable r) {
|
||||
postShutdown.add(r);
|
||||
}
|
||||
|
||||
public void onEnable() {
|
||||
if (!isPaperServer()) {
|
||||
getLogger().severe("Iris requires Paper or a Paper fork (Purpur, Pufferfish, ...). Spigot and CraftBukkit are not supported.");
|
||||
Bukkit.getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
enable();
|
||||
super.onEnable();
|
||||
Bukkit.getPluginManager().registerEvents(this, this);
|
||||
}
|
||||
|
||||
public void onDisable() {
|
||||
if (IrisSafeguard.isForceShutdown()) return;
|
||||
if (services == null || IrisSafeguard.isForceShutdown()) return;
|
||||
services.values().forEach(IrisService::onDisable);
|
||||
Bukkit.getScheduler().cancelTasks(this);
|
||||
HandlerList.unregisterAll((Plugin) this);
|
||||
postShutdown.forEach(Runnable::run);
|
||||
super.onDisable();
|
||||
|
||||
J.attempt(new JarScanner(instance.getJarFile(), "", false)::scanAll);
|
||||
J.attempt(() -> new JarScanner(instance.getJarFile(), "", false).scanAll(Iris::isForRunningServer));
|
||||
}
|
||||
|
||||
private static boolean isForRunningServer(String className) {
|
||||
// Older servers log errors for the bindings compiled for a newer Java version.
|
||||
if (!className.startsWith(NMS_BINDINGS_PACKAGE)) {
|
||||
return true;
|
||||
}
|
||||
return className.startsWith(INMS.get().getClass().getPackageName() + ".");
|
||||
}
|
||||
|
||||
private static boolean isPaperServer() {
|
||||
try {
|
||||
World.class.getMethod("getChunkAtAsync", int.class, int.class, boolean.class, boolean.class);
|
||||
return true;
|
||||
} catch (NoSuchMethodException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void setupPapi() {
|
||||
@@ -651,7 +677,7 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
.name(worldName)
|
||||
.seed(1337)
|
||||
.environment(dim.getEnvironment())
|
||||
.worldFolder(new File(Bukkit.getWorldContainer(), worldName))
|
||||
.worldFolder(WorldStorage.get().worldFolder(worldName))
|
||||
.minHeight(dim.getMinHeight())
|
||||
.maxHeight(dim.getMaxHeight())
|
||||
.build();
|
||||
@@ -673,7 +699,7 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
|
||||
@Nullable
|
||||
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
|
||||
File pack = new File(Bukkit.getWorldContainer(), String.join(File.separator, worldName, "iris", "pack"));
|
||||
File pack = new File(WorldStorage.get().worldFolder(worldName), "iris/pack");
|
||||
var dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
|
||||
if (dimension == null) dimension = IrisData.loadAnyDimension(id, null);
|
||||
if (dimension == null) {
|
||||
|
||||
@@ -147,11 +147,35 @@ public class IrisSettings {
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsPregen {
|
||||
private static final int MIN_RESIDENT_TECTONIC_PLATES = 16;
|
||||
private static final double MANTLE_HEAP_FRACTION = 0.6D;
|
||||
private static final int MEASURED_PLATE_MEGABYTES = 38;
|
||||
private static final int MEASURED_WORLD_HEIGHT = 1088;
|
||||
|
||||
public boolean useCacheByDefault = true;
|
||||
public boolean useHighPriority = false;
|
||||
public boolean useVirtualThreads = false;
|
||||
public boolean useTicketQueue = true;
|
||||
public int maxConcurrency = 256;
|
||||
public int maxResidentTectonicPlates = 96;
|
||||
public int mantleBackpressureWaitMs = 25;
|
||||
public int mantleBackpressureTimeoutMs = 60_000;
|
||||
|
||||
public int getEffectiveResidentTectonicPlates(int worldHeight) {
|
||||
// Mantle chunks only hold the sections something was written to, so shorter worlds keep the measured size.
|
||||
double heightScale = Math.max(1D, (double) worldHeight / MEASURED_WORLD_HEIGHT);
|
||||
double plateMegabytes = MEASURED_PLATE_MEGABYTES * heightScale;
|
||||
int heapBudget = (int) Math.floor(MANTLE_HEAP_FRACTION * getHardware.getProcessMemory() / plateMegabytes);
|
||||
return Math.max(MIN_RESIDENT_TECTONIC_PLATES, Math.min(maxResidentTectonicPlates, heapBudget));
|
||||
}
|
||||
|
||||
public int getMantleBackpressureWaitMs() {
|
||||
return Math.max(5, Math.min(mantleBackpressureWaitMs, 1_000));
|
||||
}
|
||||
|
||||
public int getMantleBackpressureTimeoutMs() {
|
||||
return Math.max(5_000, Math.min(mantleBackpressureTimeoutMs, 600_000));
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -242,6 +266,7 @@ public class IrisSettings {
|
||||
public boolean useServerLaunchedGuis = true;
|
||||
public boolean maximumPregenGuiFPS = false;
|
||||
public boolean colorMode = true;
|
||||
public String theme = "auto";
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.data.cache.AtomicCache;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
@@ -17,7 +18,9 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class IrisWorlds {
|
||||
@@ -34,24 +37,32 @@ public class IrisWorlds {
|
||||
}
|
||||
|
||||
public static IrisWorlds get() {
|
||||
return cache.aquire(() -> {
|
||||
File file = Iris.instance.getDataFile("worlds.json");
|
||||
if (!file.exists()) {
|
||||
return new IrisWorlds(new KMap<>());
|
||||
}
|
||||
return cache.aquire(() -> new IrisWorlds(readWorldsFile()));
|
||||
}
|
||||
|
||||
try {
|
||||
String json = IO.readAll(file);
|
||||
KMap<String, String> worlds = GSON.fromJson(json, TYPE);
|
||||
return new IrisWorlds(Objects.requireNonNullElseGet(worlds, KMap::new));
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to load worlds.json!");
|
||||
e.printStackTrace();
|
||||
Iris.reportError(e);
|
||||
}
|
||||
public static Set<String> readKnownWorldNames() {
|
||||
Set<String> names = new HashSet<>(readWorldsFile().keySet());
|
||||
names.addAll(readBukkitWorlds().keySet());
|
||||
return names;
|
||||
}
|
||||
|
||||
return new IrisWorlds(new KMap<>());
|
||||
});
|
||||
private static KMap<String, String> readWorldsFile() {
|
||||
File file = Iris.instance.getDataFile("worlds.json");
|
||||
if (!file.exists()) {
|
||||
return new KMap<>();
|
||||
}
|
||||
|
||||
try {
|
||||
String json = IO.readAll(file);
|
||||
KMap<String, String> worlds = GSON.fromJson(json, TYPE);
|
||||
return Objects.requireNonNullElseGet(worlds, KMap::new);
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to load worlds.json!");
|
||||
e.printStackTrace();
|
||||
Iris.reportError(e);
|
||||
}
|
||||
|
||||
return new KMap<>();
|
||||
}
|
||||
|
||||
public void put(String name, String type) {
|
||||
@@ -85,7 +96,7 @@ public class IrisWorlds {
|
||||
}
|
||||
|
||||
public void clean() {
|
||||
dirty = worlds.entrySet().removeIf(entry -> !new File(Bukkit.getWorldContainer(), entry.getKey() + "/iris/pack/dimensions/" + entry.getValue() + ".json").exists());
|
||||
dirty = worlds.entrySet().removeIf(entry -> !new File(WorldStorage.get().worldFolder(entry.getKey()), "iris/pack/dimensions/" + entry.getValue() + ".json").exists());
|
||||
}
|
||||
|
||||
public synchronized void save() {
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.nms.datapack.DataVersion;
|
||||
import com.volmit.iris.core.nms.datapack.IDataFixer;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.object.*;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
@@ -97,10 +98,7 @@ public class ServerConfigurator {
|
||||
if (!IrisSettings.get().getGeneral().forceMainWorld.isEmpty()) {
|
||||
return new KList<File>().qadd(new File(Bukkit.getWorldContainer(), IrisSettings.get().getGeneral().forceMainWorld + "/datapacks"));
|
||||
}
|
||||
KList<File> worlds = new KList<>();
|
||||
Bukkit.getServer().getWorlds().forEach(w -> worlds.add(new File(w.getWorldFolder(), "datapacks")));
|
||||
if (worlds.isEmpty()) worlds.add(new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME + "/datapacks"));
|
||||
return worlds;
|
||||
return WorldStorage.get().datapackFolders();
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(boolean fullInstall) {
|
||||
@@ -198,7 +196,7 @@ public class ServerConfigurator {
|
||||
}
|
||||
}
|
||||
}
|
||||
String key = getWorld(dimension.getLoader());
|
||||
String key = worldFolderNameOf(dimension.getLoader());
|
||||
if (key == null) key = dimension.getLoadKey();
|
||||
else key += "/" + dimension.getLoadKey();
|
||||
|
||||
@@ -247,14 +245,13 @@ public class ServerConfigurator {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getWorld(@NonNull IrisData data) {
|
||||
String worldContainer = Bukkit.getWorldContainer().getAbsolutePath();
|
||||
if (!worldContainer.endsWith(File.separator)) worldContainer += File.separator;
|
||||
|
||||
String path = data.getDataFolder().getAbsolutePath();
|
||||
if (!path.startsWith(worldContainer)) return null;
|
||||
int l = path.endsWith(File.separator) ? 11 : 10;
|
||||
return path.substring(worldContainer.length(), path.length() - l);
|
||||
private static String worldFolderNameOf(IrisData data) {
|
||||
File pack = data.getDataFolder();
|
||||
File iris = pack.getParentFile();
|
||||
if (iris == null || iris.getParentFile() == null || !pack.getName().equals("pack") || !iris.getName().equals("iris")) {
|
||||
return null;
|
||||
}
|
||||
return iris.getParentFile().getName();
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.volmit.iris.core.IrisSettings;
|
||||
import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
@@ -32,11 +33,8 @@ import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.decree.specialhandlers.NullablePlayerHandler;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.io.IO;
|
||||
import com.volmit.iris.util.misc.ServerProperties;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import lombok.SneakyThrows;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
@@ -45,7 +43,6 @@ import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static com.volmit.iris.core.service.EditSVC.deletingWorld;
|
||||
@@ -93,7 +90,7 @@ public class CommandIris implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
if (new File(Bukkit.getWorldContainer(), name).exists()) {
|
||||
if (WorldStorage.get().worldFolder(name).exists()) {
|
||||
sender().sendMessage(C.RED + "That folder already exists!");
|
||||
return;
|
||||
}
|
||||
@@ -108,9 +105,10 @@ public class CommandIris implements DecreeExecutor {
|
||||
.studio(false)
|
||||
.create();
|
||||
if (main) {
|
||||
WorldStorage storage = WorldStorage.get();
|
||||
Runtime.getRuntime().addShutdownHook(mainWorld.updateAndGet(old -> {
|
||||
if (old != null) Runtime.getRuntime().removeShutdownHook(old);
|
||||
return new Thread(() -> updateMainWorld(name));
|
||||
return new Thread(() -> makeMainWorld(storage, name));
|
||||
}));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
@@ -125,20 +123,12 @@ public class CommandIris implements DecreeExecutor {
|
||||
if (main) sender().sendMessage(C.GREEN + "Your world will automatically be set as the main world when the server restarts.");
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
private void updateMainWorld(String newName) {
|
||||
File worlds = Bukkit.getWorldContainer();
|
||||
var data = ServerProperties.DATA;
|
||||
try (var in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) {
|
||||
data.load(in);
|
||||
}
|
||||
for (String sub : List.of("datapacks", "playerdata", "advancements", "stats")) {
|
||||
IO.copyDirectory(new File(worlds, ServerProperties.LEVEL_NAME + "/" + sub).toPath(), new File(worlds, newName + "/" + sub).toPath());
|
||||
}
|
||||
|
||||
data.setProperty("level-name", newName);
|
||||
try (var out = new FileOutputStream(ServerProperties.SERVER_PROPERTIES)) {
|
||||
data.store(out, null);
|
||||
private static void makeMainWorld(WorldStorage storage, String worldName) {
|
||||
try {
|
||||
storage.makeMainWorld(worldName);
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to make " + worldName + " the main world!");
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,6 +460,7 @@ public class CommandIris implements DecreeExecutor {
|
||||
String world
|
||||
) {
|
||||
World worldloaded = Bukkit.getWorld(world);
|
||||
WorldStorage.get().adoptLegacyData(world);
|
||||
worldNameToCheck = world;
|
||||
boolean worldExists = doesWorldExist(worldNameToCheck);
|
||||
WorldEngine = world;
|
||||
@@ -479,8 +470,7 @@ public class CommandIris implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
String pathtodim = world + File.separator +"iris"+File.separator +"pack"+File.separator +"dimensions"+File.separator;
|
||||
File directory = new File(Bukkit.getWorldContainer(), pathtodim);
|
||||
File directory = new File(WorldStorage.get().worldFolder(world), "iris/pack/dimensions");
|
||||
|
||||
String dimension = null;
|
||||
if (directory.exists() && directory.isDirectory()) {
|
||||
@@ -533,8 +523,7 @@ public class CommandIris implements DecreeExecutor {
|
||||
}
|
||||
|
||||
boolean doesWorldExist(String worldName) {
|
||||
File worldContainer = Bukkit.getWorldContainer();
|
||||
File worldDirectory = new File(worldContainer, worldName);
|
||||
File worldDirectory = WorldStorage.get().worldFolder(worldName);
|
||||
return worldDirectory.exists() && worldDirectory.isDirectory();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public class CommandLazyPregen implements DecreeExecutor {
|
||||
) {
|
||||
|
||||
worldName = world.getName();
|
||||
File worldDirectory = new File(Bukkit.getWorldContainer(), world.getName());
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File lazyFile = new File(worldDirectory, "lazygen.json");
|
||||
if (lazyFile.exists()) {
|
||||
sender().sendMessage(C.BLUE + "Lazy pregen is already in progress");
|
||||
|
||||
@@ -20,8 +20,10 @@ package com.volmit.iris.core.commands;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.IrisSettings;
|
||||
import com.volmit.iris.core.gui.GuiHost;
|
||||
import com.volmit.iris.core.gui.MapWindow;
|
||||
import com.volmit.iris.core.gui.NoiseExplorerGUI;
|
||||
import com.volmit.iris.core.gui.VisionGUI;
|
||||
import com.volmit.iris.core.gui.PaletteExplorerStart;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.project.IrisProject;
|
||||
import com.volmit.iris.core.service.ConversionSVC;
|
||||
@@ -40,7 +42,6 @@ import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.function.Function2;
|
||||
import com.volmit.iris.util.function.NoiseProvider;
|
||||
import com.volmit.iris.util.interpolation.InterpolationMethod;
|
||||
import com.volmit.iris.util.io.IO;
|
||||
@@ -59,7 +60,6 @@ import com.volmit.iris.util.scheduling.J;
|
||||
import com.volmit.iris.util.scheduling.O;
|
||||
import com.volmit.iris.util.scheduling.PrecisionStopwatch;
|
||||
import com.volmit.iris.util.scheduling.jobs.ParallelRadiusJob;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
@@ -80,7 +80,6 @@ import java.util.Objects;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Decree(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", studio = true)
|
||||
public class CommandStudio implements DecreeExecutor {
|
||||
@@ -247,11 +246,47 @@ public class CommandStudio implements DecreeExecutor {
|
||||
engine().getExecution().execute(script.getLoadKey());
|
||||
}
|
||||
|
||||
@Decree(description = "Open the noise explorer (External GUI)", aliases = {"nmap", "n"})
|
||||
@Decree(description = "Open the map on its noise explorer (External GUI)", aliases = {"nmap", "n"})
|
||||
public void noise() {
|
||||
if (noGUI()) return;
|
||||
sender().sendMessage(C.GREEN + "Opening Noise Explorer!");
|
||||
NoiseExplorerGUI.launch();
|
||||
Engine playerEngine = engine();
|
||||
Engine engine = playerEngine != null ? playerEngine : GuiHost.findActiveEngine();
|
||||
World world = engine == null ? null : engine.getWorld().realWorld();
|
||||
if (world == null) {
|
||||
sender().sendMessage(C.RED + "Open a studio or stand in an Iris world to explore its noise!");
|
||||
return;
|
||||
}
|
||||
Location center = playerEngine != null ? player().getLocation() : null;
|
||||
sender().sendMessage(C.GREEN + "Opening the map on its noise!");
|
||||
MapWindow.openNoise(new PaletteExplorerStart(
|
||||
engine,
|
||||
world.getUID(),
|
||||
sender().isPlayer() ? player().getUniqueId() : null,
|
||||
null,
|
||||
center == null ? 0D : center.getX(),
|
||||
center == null ? 0D : center.getZ()
|
||||
));
|
||||
}
|
||||
|
||||
@Decree(description = "Preview the palettes around you (External GUI)", aliases = "pal", origin = DecreeOrigin.PLAYER)
|
||||
public void palette() {
|
||||
if (noGUI()) return;
|
||||
Engine engine = engine();
|
||||
if (engine == null) {
|
||||
sender().sendMessage(C.RED + "Stand in an Iris world to preview its palettes!");
|
||||
return;
|
||||
}
|
||||
Location location = player().getLocation();
|
||||
IrisBiome surfaceBiome = engine.getSurfaceBiome(location.getBlockX(), location.getBlockZ());
|
||||
sender().sendMessage(C.GREEN + "Opening the map on its palettes!");
|
||||
MapWindow.openPalettes(new PaletteExplorerStart(
|
||||
engine,
|
||||
location.getWorld().getUID(),
|
||||
player().getUniqueId(),
|
||||
surfaceBiome == null ? null : surfaceBiome.getLoadKey(),
|
||||
location.getX(),
|
||||
location.getZ()
|
||||
));
|
||||
}
|
||||
|
||||
@Decree(description = "Charges all spawners in the area", aliases = "zzt", origin = DecreeOrigin.PLAYER)
|
||||
@@ -273,16 +308,7 @@ public class CommandStudio implements DecreeExecutor {
|
||||
) {
|
||||
if (noGUI()) return;
|
||||
sender().sendMessage(C.GREEN + "Opening Noise Explorer!");
|
||||
|
||||
Supplier<Function2<Double, Double, Double>> l = () -> {
|
||||
|
||||
if (generator == null) {
|
||||
return (x, z) -> 0D;
|
||||
}
|
||||
|
||||
return (x, z) -> generator.getHeight(x, z, new RNG(seed).nextParallelRNG(3245).lmax());
|
||||
};
|
||||
NoiseExplorerGUI.launch(l, "Custom Generator");
|
||||
NoiseExplorerGUI.launchGeneratorKey(engine(), generator == null ? null : generator.getLoadKey(), generator, seed);
|
||||
}
|
||||
|
||||
@Decree(description = "Hotload a studio", aliases = {"reload", "h"})
|
||||
@@ -457,7 +483,15 @@ public class CommandStudio implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), 0);
|
||||
Location center = sender().isPlayer() && player().getWorld().equals(world) ? player().getLocation() : null;
|
||||
MapWindow.openMap(new PaletteExplorerStart(
|
||||
IrisToolbelt.access(world).getEngine(),
|
||||
world.getUID(),
|
||||
sender().isPlayer() ? player().getUniqueId() : null,
|
||||
null,
|
||||
center == null ? 0D : center.getX(),
|
||||
center == null ? 0D : center.getZ()
|
||||
));
|
||||
sender().sendMessage(C.GREEN + "Opening map!");
|
||||
}
|
||||
|
||||
@@ -689,7 +723,7 @@ public class CommandStudio implements DecreeExecutor {
|
||||
|
||||
sender().sendMessage(C.GREEN + "Sending you to the studio world!");
|
||||
var player = player();
|
||||
PaperLib.teleportAsync(player(), Iris.service(StudioSVC.class)
|
||||
player.teleportAsync(Iris.service(StudioSVC.class)
|
||||
.getActiveProject()
|
||||
.getActiveProvider()
|
||||
.getTarget()
|
||||
@@ -748,7 +782,6 @@ public class CommandStudio implements DecreeExecutor {
|
||||
pw.println("Iris Version: " + Iris.instance.getDescription().getVersion());
|
||||
pw.println("Bukkit Version: " + Bukkit.getBukkitVersion());
|
||||
pw.println("MC Version: " + Bukkit.getVersion());
|
||||
pw.println("PaperSpigot: " + (PaperLib.isPaper() ? "Yup!" : "Nope!"));
|
||||
pw.println("Report Captured At: " + new Date());
|
||||
pw.println("Chunks: (" + chunks.size() + "): ");
|
||||
|
||||
@@ -879,6 +912,10 @@ public class CommandStudio implements DecreeExecutor {
|
||||
sender().sendMessage(C.RED + "You must have server launched GUIs enabled in the settings!");
|
||||
return true;
|
||||
}
|
||||
if (!GuiHost.isAvailable()) {
|
||||
sender().sendMessage(C.RED + "This server runs without a desktop, so it cannot open windows!");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ public class CommandTurboPregen implements DecreeExecutor {
|
||||
) {
|
||||
|
||||
worldName = world.getName();
|
||||
File worldDirectory = new File(Bukkit.getWorldContainer(), world.getName());
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File TurboFile = new File(worldDirectory, "turbogen.json");
|
||||
if (TurboFile.exists()) {
|
||||
if (TurboPregenerator.getInstance() != null) {
|
||||
@@ -93,7 +93,7 @@ public class CommandTurboPregen implements DecreeExecutor {
|
||||
@Decree(description = "Stop the active pregeneration task", aliases = "x")
|
||||
public void stop(@Param(aliases = "world", description = "The world to pause") World world) throws IOException {
|
||||
TurboPregenerator turboPregenInstance = TurboPregenerator.getInstance();
|
||||
File worldDirectory = new File(Bukkit.getWorldContainer(), world.getName());
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File turboFile = new File(worldDirectory, "turbogen.json");
|
||||
|
||||
if (turboPregenInstance != null) {
|
||||
@@ -117,7 +117,7 @@ public class CommandTurboPregen implements DecreeExecutor {
|
||||
TurboPregenerator.setPausedTurbo(world);
|
||||
sender().sendMessage(C.GREEN + "Paused/unpaused Turbo Pregen, now: " + (TurboPregenerator.isPausedTurbo(world) ? "Paused" : "Running") + ".");
|
||||
} else {
|
||||
File worldDirectory = new File(Bukkit.getWorldContainer(), world.getName());
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File TurboFile = new File(worldDirectory, "turbogen.json");
|
||||
if (TurboFile.exists()){
|
||||
TurboPregenerator.loadTurboGenerator(world.getName());
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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.PickMeasuring.TypedSpread;
|
||||
import com.volmit.iris.core.gui.components.PickDisplay;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.util.stream.ProceduralStream;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The biome view's explorer: every region picks the biomes on its ground from its own biome lists, so for each region
|
||||
* in view it shows how its noise, or in hybrid terrain the height, picks them.
|
||||
*/
|
||||
final class BiomeExplorer {
|
||||
private final Supplier<Engine> engineSource;
|
||||
private final PickInspectorPanel inspector;
|
||||
private final LatestMeasurement<Measurement> measurer = new LatestMeasurement<>("Iris Biome Explorer");
|
||||
private MapCensus.ViewArea latestArea;
|
||||
private MapCensus.ViewArea measuredArea;
|
||||
|
||||
/**
|
||||
* @param onDisplayChosen receives how the map should draw the picks
|
||||
*/
|
||||
BiomeExplorer(Supplier<Engine> engineSource, Consumer<PickDisplay> onDisplayChosen) {
|
||||
this.engineSource = engineSource;
|
||||
this.inspector = new PickInspectorPanel("Biomes", onDisplayChosen);
|
||||
}
|
||||
|
||||
PickInspectorPanel inspector() {
|
||||
return inspector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the regions in this area and how they pick their biomes, unless that was already done.
|
||||
*/
|
||||
void measure(MapCensus.ViewArea area) {
|
||||
latestArea = area;
|
||||
if (area == null || area.equals(measuredArea)) {
|
||||
return;
|
||||
}
|
||||
measuredArea = area;
|
||||
measurer.measure(() -> measurePicks(area), this::show);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pack changed, so the regions and their biomes are read again.
|
||||
*/
|
||||
void refresh() {
|
||||
measuredArea = null;
|
||||
measure(latestArea);
|
||||
}
|
||||
|
||||
void close() {
|
||||
measurer.close();
|
||||
}
|
||||
|
||||
private void show(Measurement measurement) {
|
||||
List<PickInspectorPanel.Section> sections = new ArrayList<>();
|
||||
for (RegionPicks region : measurement.regions()) {
|
||||
List<JComponent> charts = region.coverage() != null
|
||||
? List.of(PickInspectorPanel.heightChart(region.coverage()))
|
||||
: region.spreads().stream()
|
||||
.map(spread -> (JComponent) inspector.noiseChart(spread, "Biomes", region.key() + "/" + spread.title()))
|
||||
.toList();
|
||||
sections.add(new PickInspectorPanel.Section(region.key(), region.name(), region.color(), region.share(),
|
||||
biomeCountText(region.biomeCount()), charts, region.notes()));
|
||||
}
|
||||
String detail = measurement.hybrid()
|
||||
? "Hybrid terrain · each region picks biomes by height"
|
||||
: "Each region splits its own biome noise by rarity";
|
||||
inspector.showRegionSections(detail, sections, measurement.notes());
|
||||
}
|
||||
|
||||
private static String biomeCountText(int count) {
|
||||
return count == 1 ? "1 biome" : count + " biomes";
|
||||
}
|
||||
|
||||
private Measurement measurePicks(MapCensus.ViewArea area) {
|
||||
Engine engine = engineSource.get();
|
||||
IrisComplex complex = engine == null ? null : engine.getComplex();
|
||||
if (complex == null) {
|
||||
return null;
|
||||
}
|
||||
Map<IrisRegion, RegionSamples> samplesByRegion = new IdentityHashMap<>();
|
||||
int[] total = {0};
|
||||
PickMeasuring.forEachSample(area, (x, z) -> {
|
||||
IrisRegion region = complex.getRegionStream().get(x, z);
|
||||
if (region == null) {
|
||||
return;
|
||||
}
|
||||
total[0]++;
|
||||
samplesByRegion.computeIfAbsent(region, subject -> new RegionSamples(engine, complex, subject)).add(x, z);
|
||||
});
|
||||
List<RegionPicks> regions = samplesByRegion.values().stream()
|
||||
.sorted(Comparator.comparingInt(RegionSamples::count).reversed())
|
||||
.map(samples -> samples.picks(engine, total[0]))
|
||||
.toList();
|
||||
List<String> notes = new ArrayList<>();
|
||||
if (complex.isHybridTerrain()) {
|
||||
notes.add(PickMeasuring.hybridNoiseNote());
|
||||
} else {
|
||||
PickMeasuring.addRarityNotes(notes, complex, regions.stream().flatMap(region -> region.spreads().stream()).toList());
|
||||
}
|
||||
notes.add(PickMeasuring.measuredNote());
|
||||
return new Measurement(complex.isHybridTerrain(), regions, notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spots in view one region owns. The biome view shows each spot's base biome, which the region picks from the
|
||||
* biome list of the spot's continent type.
|
||||
*/
|
||||
private static final class RegionSamples {
|
||||
private final IrisComplex complex;
|
||||
private final IrisRegion region;
|
||||
private final List<TypedSpread> spreads;
|
||||
private final Map<InferredType, ProceduralStream<Double>> noiseByType = new EnumMap<>(InferredType.class);
|
||||
private final List<Integer> pickingHeights = new ArrayList<>();
|
||||
private int count;
|
||||
|
||||
private RegionSamples(Engine engine, IrisComplex complex, IrisRegion region) {
|
||||
this.complex = complex;
|
||||
this.region = region;
|
||||
this.spreads = complex.isHybridTerrain() ? List.of() : PickMeasuring.biomeSpreads(engine, complex, region);
|
||||
}
|
||||
|
||||
private void add(double x, double z) {
|
||||
count++;
|
||||
if (complex.isHybridTerrain()) {
|
||||
pickingHeights.add((int) Math.round(complex.getPlacementHeightStream().get(x, z)));
|
||||
return;
|
||||
}
|
||||
InferredType type = complex.getBridgeStream().get(x, z);
|
||||
for (TypedSpread typed : spreads) {
|
||||
if (typed.type() == type) {
|
||||
typed.spread().add(noiseByType.computeIfAbsent(type, shown -> complex.biomePickNoiseStream(region, shown)).get(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int count() {
|
||||
return count;
|
||||
}
|
||||
|
||||
private RegionPicks picks(Engine engine, int totalCount) {
|
||||
double share = totalCount == 0 ? 0D : count / (double) totalCount;
|
||||
String key = region.getLoadKey();
|
||||
String name = region.getName();
|
||||
int color = PickMeasuring.regionColor(engine, region);
|
||||
List<String> notes = new ArrayList<>();
|
||||
if (complex.isHybridTerrain()) {
|
||||
List<RegionHeightCoverage.BiomeRange> ranges = PickMeasuring.biomeRanges(engine, region);
|
||||
RegionHeightCoverage coverage = RegionHeightCoverage.measure(ranges,
|
||||
pickingHeights.stream().mapToInt(Integer::intValue).toArray());
|
||||
notes.addAll(coverage.notes());
|
||||
return new RegionPicks(key, name, color, share, ranges.size(), List.of(), coverage, notes);
|
||||
}
|
||||
List<TypedSpread> inView = spreads.stream().filter(typed -> typed.spread().sampleCount() > 0).toList();
|
||||
PickMeasuring.addBiomeSpreadNotes(notes, inView);
|
||||
PickMeasuring.addChildBiomeNote(notes, complex, region);
|
||||
int biomeCount = (int) inView.stream()
|
||||
.flatMap(typed -> typed.spread().rows().stream())
|
||||
.map(row -> row.option().key())
|
||||
.distinct()
|
||||
.count();
|
||||
return new RegionPicks(key, name, color, share, biomeCount, inView.stream().map(TypedSpread::spread).toList(),
|
||||
null, notes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param coverage how the region's biomes cover the heights in view in hybrid terrain, or null in biome terrain
|
||||
*/
|
||||
private record RegionPicks(String key, String name, int color, double share, int biomeCount,
|
||||
List<NoisePickSpread> spreads, RegionHeightCoverage coverage, List<String> notes) {
|
||||
}
|
||||
|
||||
private record Measurement(boolean hybrid, List<RegionPicks> regions, List<String> notes) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 java.util.Random;
|
||||
|
||||
// A copy of Minecraft's Biome.BIOME_INFO_NOISE, the 2D simplex noise swamps use to pick between their two grass colours.
|
||||
// Minecraft seeds it through a LegacyRandomSource, which draws the same numbers as java.util.Random.
|
||||
final class BiomeInfoNoise {
|
||||
private static final long SEED = 2345L;
|
||||
private static final int PERMUTATION_SIZE = 256;
|
||||
private static final int GRADIENT_COUNT = 12;
|
||||
private static final double SKEW = 0.5D * (Math.sqrt(3D) - 1D);
|
||||
private static final double UNSKEW = (3D - Math.sqrt(3D)) / 6D;
|
||||
private static final double CORNER_RADIUS = 0.5D;
|
||||
private static final double OUTPUT_SCALE = 70D;
|
||||
private static final int[][] GRADIENTS = {
|
||||
{1, 1}, {-1, 1}, {1, -1}, {-1, -1},
|
||||
{1, 0}, {-1, 0}, {1, 0}, {-1, 0},
|
||||
{0, 1}, {0, -1}, {0, 1}, {0, -1}
|
||||
};
|
||||
private static final int[] PERMUTATION = createPermutation();
|
||||
|
||||
private BiomeInfoNoise() {
|
||||
}
|
||||
|
||||
static float at(double x, double z) {
|
||||
double skewOffset = (x + z) * SKEW;
|
||||
int cellX = (int) Math.floor(x + skewOffset);
|
||||
int cellZ = (int) Math.floor(z + skewOffset);
|
||||
double unskewOffset = (cellX + cellZ) * UNSKEW;
|
||||
double x0 = x - (cellX - unskewOffset);
|
||||
double z0 = z - (cellZ - unskewOffset);
|
||||
int stepX = x0 > z0 ? 1 : 0;
|
||||
int stepZ = 1 - stepX;
|
||||
double x1 = x0 - stepX + UNSKEW;
|
||||
double z1 = z0 - stepZ + UNSKEW;
|
||||
double x2 = x0 - 1D + 2D * UNSKEW;
|
||||
double z2 = z0 - 1D + 2D * UNSKEW;
|
||||
int wrappedX = cellX & 0xFF;
|
||||
int wrappedZ = cellZ & 0xFF;
|
||||
int gradient0 = permute(wrappedX + permute(wrappedZ)) % GRADIENT_COUNT;
|
||||
int gradient1 = permute(wrappedX + stepX + permute(wrappedZ + stepZ)) % GRADIENT_COUNT;
|
||||
int gradient2 = permute(wrappedX + 1 + permute(wrappedZ + 1)) % GRADIENT_COUNT;
|
||||
return (float) (OUTPUT_SCALE * (corner(gradient0, x0, z0) + corner(gradient1, x1, z1) + corner(gradient2, x2, z2)));
|
||||
}
|
||||
|
||||
private static double corner(int gradient, double x, double z) {
|
||||
double falloff = CORNER_RADIUS - x * x - z * z;
|
||||
if (falloff < 0D) {
|
||||
return 0D;
|
||||
}
|
||||
falloff *= falloff;
|
||||
return falloff * falloff * (GRADIENTS[gradient][0] * x + GRADIENTS[gradient][1] * z);
|
||||
}
|
||||
|
||||
private static int permute(int index) {
|
||||
return PERMUTATION[index & 0xFF];
|
||||
}
|
||||
|
||||
// Minecraft draws three offsets first and discards them for this noise, so they are drawn here too to stay in step.
|
||||
private static int[] createPermutation() {
|
||||
Random random = new Random(SEED);
|
||||
for (int discardedOffset = 0; discardedOffset < 3; discardedOffset++) {
|
||||
random.nextDouble();
|
||||
}
|
||||
int[] permutation = new int[PERMUTATION_SIZE];
|
||||
for (int i = 0; i < PERMUTATION_SIZE; i++) {
|
||||
permutation[i] = i;
|
||||
}
|
||||
for (int i = 0; i < PERMUTATION_SIZE; i++) {
|
||||
int swap = i + random.nextInt(PERMUTATION_SIZE - i);
|
||||
int previous = permutation[i];
|
||||
permutation[i] = permutation[swap];
|
||||
permutation[swap] = previous;
|
||||
}
|
||||
return permutation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import com.volmit.iris.engine.object.IrisBlockData;
|
||||
import com.volmit.iris.engine.object.IrisDecorator;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* One of the block palettes a biome places: a surface, sea or cave ceiling layer, its wall or slab blocks, or a
|
||||
* decorator.
|
||||
*/
|
||||
record BiomePalette(String biomeKey, Kind kind, int index) {
|
||||
private static final String ID_PREFIX = "palette:";
|
||||
private static final char ID_SEPARATOR = '#';
|
||||
private static final String SNIPPET_PREFIX = "snippet/";
|
||||
|
||||
enum Kind {
|
||||
LAYER("Layer", "layers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
SEA_LAYER("Sea layer", "seaLayers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
WALL("Wall", "wall", SnippetFolder.BLOCK_PALETTES, false, false),
|
||||
SLAB("Slab", "slab", SnippetFolder.BLOCK_PALETTES, false, false),
|
||||
DECORATOR("Decorator", "decorators", SnippetFolder.DECORATORS, false, true);
|
||||
|
||||
private final String label;
|
||||
private final String jsonKey;
|
||||
private final SnippetFolder snippetFolder;
|
||||
private final boolean stacked;
|
||||
private final boolean numbered;
|
||||
|
||||
Kind(String label, String jsonKey, SnippetFolder snippetFolder, boolean stacked, boolean numbered) {
|
||||
this.label = label;
|
||||
this.jsonKey = jsonKey;
|
||||
this.snippetFolder = snippetFolder;
|
||||
this.stacked = stacked;
|
||||
this.numbered = numbered;
|
||||
}
|
||||
|
||||
SnippetFolder snippetFolder() {
|
||||
return snippetFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the palette is a stack of blocks with a thickness, rather than single blocks on walls or slopes.
|
||||
*/
|
||||
boolean isStacked() {
|
||||
return stacked;
|
||||
}
|
||||
|
||||
/**
|
||||
* The biome file's key holding these palettes: a list of them, or a single one for walls and slabs.
|
||||
*/
|
||||
String jsonKey() {
|
||||
return jsonKey;
|
||||
}
|
||||
|
||||
// A biome without wall or slab blocks still has those palettes, just empty.
|
||||
List<IrisBiomePaletteLayer> layersOf(IrisBiome biome) {
|
||||
return switch (this) {
|
||||
case LAYER -> biome.getLayers();
|
||||
case SEA_LAYER -> biome.getSeaLayers();
|
||||
case CAVE_CEILING_LAYER -> biome.getCaveCeilingLayers();
|
||||
case WALL -> biome.getWall().getPalette().isEmpty() ? List.of() : List.of(biome.getWall());
|
||||
case SLAB -> biome.getSlab().getPalette().isEmpty() ? List.of() : List.of(biome.getSlab());
|
||||
case DECORATOR -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
// A decorator snippet that couldn't be found loads as an empty entry, which places nothing.
|
||||
List<KList<IrisBlockData>> blockListsOf(IrisBiome biome) {
|
||||
if (this != DECORATOR) {
|
||||
return layersOf(biome).stream().map(IrisBiomePaletteLayer::getPalette).toList();
|
||||
}
|
||||
return biome.getDecorators().stream()
|
||||
.map(decorator -> decorator == null ? new KList<IrisBlockData>() : decorator.getPalette())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders under snippet/ that palettes can come from, named after the Iris type they hold.
|
||||
*/
|
||||
enum SnippetFolder {
|
||||
BLOCK_PALETTES("biome-palette", "Block palettes"),
|
||||
DECORATORS("decorator", "Decorators");
|
||||
|
||||
private final String folder;
|
||||
private final String label;
|
||||
|
||||
SnippetFolder(String folder, String label) {
|
||||
this.folder = folder;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String folder() {
|
||||
return folder;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippet's path inside this folder. Packs may leave the folder out, the way the snippet loader allows.
|
||||
*/
|
||||
String pathOf(String reference) {
|
||||
String path = reference.startsWith(SNIPPET_PREFIX) ? reference.substring(SNIPPET_PREFIX.length()) : reference;
|
||||
return path.startsWith(folder + "/") ? path.substring(folder.length() + 1) : path;
|
||||
}
|
||||
}
|
||||
|
||||
static List<BiomePalette> allOf(String biomeKey, IrisBiome biome) {
|
||||
List<BiomePalette> palettes = new ArrayList<>();
|
||||
for (Kind kind : Kind.values()) {
|
||||
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
|
||||
for (int index = 0; index < blockLists.size(); index++) {
|
||||
if (!blockLists.get(index).isEmpty()) {
|
||||
palettes.add(new BiomePalette(biomeKey, kind, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(palettes);
|
||||
}
|
||||
|
||||
static BiomePalette fromId(String id) {
|
||||
if (id == null || !id.startsWith(ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
int indexSeparator = id.lastIndexOf(ID_SEPARATOR);
|
||||
int kindSeparator = id.lastIndexOf(ID_SEPARATOR, indexSeparator - 1);
|
||||
if (kindSeparator < ID_PREFIX.length()) {
|
||||
return null;
|
||||
}
|
||||
return new BiomePalette(
|
||||
id.substring(ID_PREFIX.length(), kindSeparator),
|
||||
Kind.valueOf(id.substring(kindSeparator + 1, indexSeparator)),
|
||||
Integer.parseInt(id.substring(indexSeparator + 1))
|
||||
);
|
||||
}
|
||||
|
||||
static boolean belongsTo(String id, String biomeKey) {
|
||||
return id != null && id.startsWith(ID_PREFIX + biomeKey + ID_SEPARATOR);
|
||||
}
|
||||
|
||||
String id() {
|
||||
return ID_PREFIX + biomeKey + ID_SEPARATOR + kind.name() + ID_SEPARATOR + index;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return kind.numbered ? kind.label + " " + index : kind.label;
|
||||
}
|
||||
|
||||
String lowercaseLabel() {
|
||||
return label().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette's blocks in the given copy of the biome, or null when a hotload removed it.
|
||||
*/
|
||||
KList<IrisBlockData> blocksIn(IrisBiome biome) {
|
||||
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
|
||||
return index < blockLists.size() ? blockLists.get(index) : null;
|
||||
}
|
||||
|
||||
IrisBiomePaletteLayer layerIn(IrisBiome biome) {
|
||||
return kind.layersOf(biome).get(index);
|
||||
}
|
||||
|
||||
IrisDecorator decoratorIn(IrisBiome biome) {
|
||||
return biome.getDecorators().get(index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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.decorator.IrisEngineDecorator;
|
||||
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.IrisBlockData;
|
||||
import com.volmit.iris.engine.object.IrisDecorationPart;
|
||||
import com.volmit.iris.engine.object.IrisDecorator;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import com.volmit.iris.util.noise.CNG;
|
||||
import org.bukkit.block.data.Bisected;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* How thick one biome palette is at a spot and which noise picks its block there, sampled exactly the way the
|
||||
* generator does for that kind of palette. The generator seeds each palette's noise from the first call it makes,
|
||||
* so these use the same random sources the terrain, carving, post and decorator passes use.
|
||||
*/
|
||||
abstract class BiomePalettePlacement {
|
||||
protected final IrisData data;
|
||||
|
||||
private BiomePalettePlacement(IrisData data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static BiomePalettePlacement of(Engine engine, IrisBiome biome, BiomePalette palette) {
|
||||
IrisData data = engine.getData();
|
||||
RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain());
|
||||
RNG carveRandom = new RNG(engine.getSeedManager().getCarve());
|
||||
RNG postRandom = new RNG(engine.getSeedManager().getPost());
|
||||
int seaLevel = seaLevel(engine.getDimension());
|
||||
return switch (palette.kind()) {
|
||||
case LAYER -> new SurfaceLayer(biome, palette.index(), palette.layerIn(biome), terrainRandom, data);
|
||||
case SEA_LAYER -> new LegacyStackedLayer(palette.layerIn(biome), data, terrainRandom.nextParallelRNG(palette.index()),
|
||||
biome.getLayerSeaHeightGenerators(terrainRandom, data), palette.index());
|
||||
case CAVE_CEILING_LAYER -> new LegacyStackedLayer(palette.layerIn(biome), data, carveRandom.nextParallelRNG(palette.index()),
|
||||
biome.getLayerHeightGenerators(terrainRandom, data), palette.index());
|
||||
case WALL -> new SingleBlocks(palette.layerIn(biome), data, carveRandom, seaLevel);
|
||||
case SLAB -> new SingleBlocks(palette.layerIn(biome), data, postRandom, seaLevel);
|
||||
case DECORATOR -> new Decorations(biome, palette.decoratorIn(biome), engine.getSeedManager().getComponent(),
|
||||
seaLevel, data);
|
||||
};
|
||||
}
|
||||
|
||||
private static int seaLevel(IrisDimension dimension) {
|
||||
return dimension == null ? 0 : dimension.getFluidHeight();
|
||||
}
|
||||
|
||||
abstract KList<IrisBlockData> blocks();
|
||||
|
||||
abstract double zoom();
|
||||
|
||||
int minThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int maxThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
abstract boolean hasSlopeCondition();
|
||||
|
||||
/**
|
||||
* How many blocks of this palette are stacked at the spot.
|
||||
*/
|
||||
abstract int thickness(int blockX, int blockZ);
|
||||
|
||||
abstract double noise(int blockX, int blockZ, int depth);
|
||||
|
||||
/**
|
||||
* What the preview simplifies about this palette, or null when it shows exactly what the world places.
|
||||
*/
|
||||
String simplification() {
|
||||
return null;
|
||||
}
|
||||
|
||||
private abstract static class LayerPlacement extends BiomePalettePlacement {
|
||||
protected final IrisBiomePaletteLayer layer;
|
||||
|
||||
private LayerPlacement(IrisBiomePaletteLayer layer, IrisData data) {
|
||||
super(data);
|
||||
this.layer = layer;
|
||||
}
|
||||
|
||||
@Override
|
||||
KList<IrisBlockData> blocks() {
|
||||
return layer.getPalette();
|
||||
}
|
||||
|
||||
@Override
|
||||
double zoom() {
|
||||
return layer.getZoom();
|
||||
}
|
||||
|
||||
@Override
|
||||
int minThickness() {
|
||||
return layer.getMinHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
int maxThickness() {
|
||||
return layer.getMaxHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean hasSlopeCondition() {
|
||||
return !layer.getSlopeCondition().isDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SurfaceLayer extends LayerPlacement {
|
||||
private final IrisBiome biome;
|
||||
private final int index;
|
||||
private final RNG random;
|
||||
|
||||
private SurfaceLayer(IrisBiome biome, int index, IrisBiomePaletteLayer layer, RNG random, IrisData data) {
|
||||
super(layer, data);
|
||||
this.biome = biome;
|
||||
this.index = index;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
return biome.getLayerThickness(index, blockX, blockZ, random, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
return biome.getLayerNoise(index, blockX, blockZ, depth, random, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sea and cave ceiling layers go through the older sampling path, which divides by the zoom twice and borrows
|
||||
* its thickness noise from another list: a cave ceiling layer uses the surface layer at the same position's.
|
||||
*/
|
||||
private static final class LegacyStackedLayer extends LayerPlacement {
|
||||
private final RNG random;
|
||||
private final CNG thicknessNoise;
|
||||
|
||||
private LegacyStackedLayer(IrisBiomePaletteLayer layer, IrisData data, RNG random, KList<CNG> thicknessNoises,
|
||||
int index) {
|
||||
super(layer, data);
|
||||
this.random = random;
|
||||
this.thicknessNoise = index < thicknessNoises.size() ? thicknessNoises.get(index) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
if (thicknessNoise == null) {
|
||||
return 0;
|
||||
}
|
||||
double zoom = layer.getZoom();
|
||||
return Math.max(0, thicknessNoise.fit(layer.getMinHeight(), layer.getMaxHeight(), blockX / zoom, blockZ / zoom));
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
double zoom = layer.getZoom();
|
||||
return layer.getLayerGenerator(random, data)
|
||||
.noise((blockX + depth) / zoom / zoom, depth / zoom, (blockZ - depth) / zoom / zoom);
|
||||
}
|
||||
|
||||
@Override
|
||||
String simplification() {
|
||||
return thicknessNoise == null
|
||||
? "The generator takes this layer's thickness from the surface layer at the same position, which doesn't exist, so it is never placed."
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall and slab blocks are picked one at a time with noise that also changes with height.
|
||||
*/
|
||||
private static final class SingleBlocks extends LayerPlacement {
|
||||
private final RNG random;
|
||||
private final int sampleHeight;
|
||||
|
||||
private SingleBlocks(IrisBiomePaletteLayer layer, IrisData data, RNG random, int sampleHeight) {
|
||||
super(layer, data);
|
||||
this.random = random;
|
||||
this.sampleHeight = sampleHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
int minThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
int maxThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
double zoom = layer.getZoom();
|
||||
return layer.getLayerGenerator(random, data).noise(blockX / zoom, sampleHeight / zoom, blockZ / zoom);
|
||||
}
|
||||
|
||||
@Override
|
||||
String simplification() {
|
||||
return "These blocks also change with height; the preview shows them at sea level (Y " + sampleHeight + ").";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorator places where its style noise stays under its chance, and its variance noise picks the block there.
|
||||
* The generator seeds the variance noise from whichever block it decorates first, so the preview reuses that noise
|
||||
* once the world has generated some decorations.
|
||||
*/
|
||||
private static final class Decorations extends BiomePalettePlacement {
|
||||
private final IrisDecorator decorator;
|
||||
private final CNG placementNoise;
|
||||
private final CNG blockNoise;
|
||||
private final int sampleHeight;
|
||||
private final boolean sharesSpotsWithOtherDecorators;
|
||||
|
||||
private Decorations(IrisBiome biome, IrisDecorator decorator, long componentSeed, int sampleHeight, IrisData data) {
|
||||
super(data);
|
||||
this.decorator = decorator;
|
||||
RNG partRandom = new RNG(IrisEngineDecorator.partSeed(componentSeed, decorator.getPartOf()));
|
||||
this.placementNoise = decorator.getGenerator(partRandom, data);
|
||||
this.blockNoise = decorator.getVarianceGenerator(partRandom, data);
|
||||
this.sampleHeight = sampleHeight;
|
||||
this.sharesSpotsWithOtherDecorators = biome.getDecorators().stream()
|
||||
.anyMatch(other -> other != null && other != decorator && other.getPartOf() == decorator.getPartOf());
|
||||
}
|
||||
|
||||
@Override
|
||||
KList<IrisBlockData> blocks() {
|
||||
return decorator.getPalette();
|
||||
}
|
||||
|
||||
@Override
|
||||
double zoom() {
|
||||
return decorator.getStyle().getZoom();
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean hasSlopeCondition() {
|
||||
return !decorator.getSlopeCondition().isDefault();
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
double zoom = zoom();
|
||||
return placementNoise.fitDouble(0D, 1D, blockX / zoom, blockZ / zoom) <= decorator.getChance() ? 1 : 0;
|
||||
}
|
||||
|
||||
// The generator swaps X and Z when it picks the block.
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
return blockNoise.noise(blockZ, sampleHeight, blockX);
|
||||
}
|
||||
|
||||
@Override
|
||||
String simplification() {
|
||||
List<String> notes = new ArrayList<>();
|
||||
String partNote = partNote(decorator.getPartOf());
|
||||
if (partNote != null) {
|
||||
notes.add(partNote);
|
||||
}
|
||||
if (sharesSpotsWithOtherDecorators) {
|
||||
notes.add("Where other decorators of this biome could place too, the world picks one of them at random.");
|
||||
}
|
||||
if (placesTwoBlockTallPlants()) {
|
||||
notes.add("Two block tall plants stand up on the map: their top half covers the block above them.");
|
||||
}
|
||||
if (decorator.isStacking()) {
|
||||
notes.add("It stacks " + decorator.getStackMin() + "–" + decorator.getStackMax()
|
||||
+ " blocks high; the preview shows the bottom block.");
|
||||
}
|
||||
if (blocks().size() > 1) {
|
||||
notes.add("The block also changes with height; the preview picks it at sea level (Y " + sampleHeight + ").");
|
||||
}
|
||||
return notes.isEmpty() ? null : String.join(" ", notes);
|
||||
}
|
||||
|
||||
// The generator places any two-part block as a bottom half with its top half above it.
|
||||
private boolean placesTwoBlockTallPlants() {
|
||||
return !decorator.isStacking() && blocks().stream().anyMatch(block -> block.getBlockData(data) instanceof Bisected);
|
||||
}
|
||||
|
||||
private static String partNote(IrisDecorationPart part) {
|
||||
return switch (part) {
|
||||
case NONE -> null;
|
||||
case SHORE_LINE -> "It only places along shore lines.";
|
||||
case SEA_SURFACE -> "It only places on the sea surface.";
|
||||
case SEA_FLOOR -> "It only places on the sea floor.";
|
||||
case CEILING -> "It places on cave ceilings and the undersides of overhangs, not on the ground.";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -16,10 +16,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.util.reflect;
|
||||
|
||||
public class ShadeFix {
|
||||
public static void fix(Class<?> c) {
|
||||
c.getCanonicalName();
|
||||
}
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
enum BiomeTint {
|
||||
GRASS,
|
||||
FOLIAGE,
|
||||
DRY_FOLIAGE,
|
||||
WATER
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 java.util.Locale;
|
||||
|
||||
// The colours one Minecraft biome multiplies onto tinted blocks, as worked out by Biome#getGrassColor and friends.
|
||||
record BiomeTintColors(int grass, GrassColorModifier grassModifier, int foliage, int dryFoliage, int water) {
|
||||
int colorAt(BiomeTint tint, int blockX, int blockZ) {
|
||||
return switch (tint) {
|
||||
case GRASS -> grassModifier.modify(blockX, blockZ, grass);
|
||||
case FOLIAGE -> foliage;
|
||||
case DRY_FOLIAGE -> dryFoliage;
|
||||
case WATER -> water;
|
||||
};
|
||||
}
|
||||
|
||||
boolean variesByPosition() {
|
||||
return grassModifier == GrassColorModifier.SWAMP;
|
||||
}
|
||||
|
||||
enum GrassColorModifier {
|
||||
NONE,
|
||||
DARK_FOREST,
|
||||
SWAMP;
|
||||
|
||||
private static final double SWAMP_NOISE_SCALE = 0.0225D;
|
||||
private static final double SWAMP_WET_THRESHOLD = -0.1D;
|
||||
private static final int SWAMP_WET_GRASS = 0x4C763C;
|
||||
private static final int SWAMP_DRY_GRASS = 0x6A7039;
|
||||
private static final int DARK_FOREST_SHIFT = 0x28340A;
|
||||
|
||||
static GrassColorModifier named(String name) {
|
||||
return switch (name.toLowerCase(Locale.ROOT)) {
|
||||
case "dark_forest" -> DARK_FOREST;
|
||||
case "swamp" -> SWAMP;
|
||||
default -> NONE;
|
||||
};
|
||||
}
|
||||
|
||||
int modify(int blockX, int blockZ, int baseColor) {
|
||||
return switch (this) {
|
||||
case NONE -> baseColor;
|
||||
case DARK_FOREST -> ((baseColor & 0xFEFEFE) + DARK_FOREST_SHIFT) >> 1;
|
||||
case SWAMP -> BiomeInfoNoise.at(blockX * SWAMP_NOISE_SCALE, blockZ * SWAMP_NOISE_SCALE) < SWAMP_WET_THRESHOLD
|
||||
? SWAMP_WET_GRASS
|
||||
: SWAMP_DRY_GRASS;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomeCustom;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
// The tint Minecraft draws on each column of one Iris biome. Iris picks the Minecraft biome once per chunk
|
||||
// (see IrisBiomeActuator), and the client averages the colours of the columns around each block.
|
||||
final class BiomeTintField {
|
||||
private static final int MINECRAFT_DEFAULT_BLEND_RADIUS = 2;
|
||||
private static final int BLENDED_COLUMN_COUNT = (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1) * (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1);
|
||||
private static final int BLENDED_TINT_CACHE_SIZE = 1 << 16;
|
||||
private static final int CHUNK_COLORS_CACHE_SIZE = 1 << 10;
|
||||
|
||||
private final IrisBiome biome;
|
||||
private final RNG biomeRandom;
|
||||
private final MinecraftBiomeColors minecraftColors;
|
||||
private final BiomeTintColors sameColorsEverywhere;
|
||||
private final Map<IrisBiomeCustom, BiomeTintColors> colorsByCustomBiome = new ConcurrentHashMap<>();
|
||||
private final BlendedTint[] blendedTintCache = new BlendedTint[BLENDED_TINT_CACHE_SIZE];
|
||||
private final ChunkColors[] chunkColorsCache = new ChunkColors[CHUNK_COLORS_CACHE_SIZE];
|
||||
|
||||
BiomeTintField(IrisBiome biome, RNG biomeRandom, MinecraftBiomeColors minecraftColors) {
|
||||
this.biome = biome;
|
||||
this.biomeRandom = biomeRandom;
|
||||
this.minecraftColors = minecraftColors;
|
||||
BiomeTintColors onlyColors = onlyPossibleColors();
|
||||
this.sameColorsEverywhere = onlyColors == null || onlyColors.variesByPosition() ? null : onlyColors;
|
||||
}
|
||||
|
||||
int colorAt(BiomeTint tint, int blockX, int blockZ) {
|
||||
if (sameColorsEverywhere != null) {
|
||||
return sameColorsEverywhere.colorAt(tint, blockX, blockZ);
|
||||
}
|
||||
int cacheSlot = cacheSlot(blockX, blockZ, tint.ordinal(), BLENDED_TINT_CACHE_SIZE);
|
||||
BlendedTint cached = blendedTintCache[cacheSlot];
|
||||
if (cached != null && cached.x() == blockX && cached.z() == blockZ && cached.tint() == tint) {
|
||||
return cached.color();
|
||||
}
|
||||
int color = blendedColor(tint, blockX, blockZ);
|
||||
blendedTintCache[cacheSlot] = new BlendedTint(blockX, blockZ, tint, color);
|
||||
return color;
|
||||
}
|
||||
|
||||
// Minecraft's ClientLevel#calculateBlockTint at the default biome blend radius.
|
||||
private int blendedColor(BiomeTint tint, int blockX, int blockZ) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int x = blockX - MINECRAFT_DEFAULT_BLEND_RADIUS; x <= blockX + MINECRAFT_DEFAULT_BLEND_RADIUS; x++) {
|
||||
for (int z = blockZ - MINECRAFT_DEFAULT_BLEND_RADIUS; z <= blockZ + MINECRAFT_DEFAULT_BLEND_RADIUS; z++) {
|
||||
int color = chunkColors(x >> 4, z >> 4).colorAt(tint, x, z);
|
||||
red += (color >>> 16) & 0xFF;
|
||||
green += (color >>> 8) & 0xFF;
|
||||
blue += color & 0xFF;
|
||||
}
|
||||
}
|
||||
return (red / BLENDED_COLUMN_COUNT) << 16 | (green / BLENDED_COLUMN_COUNT) << 8 | (blue / BLENDED_COLUMN_COUNT);
|
||||
}
|
||||
|
||||
private BiomeTintColors chunkColors(int chunkX, int chunkZ) {
|
||||
int cacheSlot = cacheSlot(chunkX, chunkZ, 0, CHUNK_COLORS_CACHE_SIZE);
|
||||
ChunkColors cached = chunkColorsCache[cacheSlot];
|
||||
if (cached != null && cached.chunkX() == chunkX && cached.chunkZ() == chunkZ) {
|
||||
return cached.colors();
|
||||
}
|
||||
BiomeTintColors colors = colorsForChunkAt(chunkX << 4, chunkZ << 4);
|
||||
chunkColorsCache[cacheSlot] = new ChunkColors(chunkX, chunkZ, colors);
|
||||
return colors;
|
||||
}
|
||||
|
||||
private BiomeTintColors colorsForChunkAt(int originX, int originZ) {
|
||||
if (biome.isCustom()) {
|
||||
return customColors(biome.getCustomBiome(biomeRandom, originX, 0, originZ));
|
||||
}
|
||||
return vanillaColors(biome.getSkyBiome(biomeRandom, originX, 0, originZ));
|
||||
}
|
||||
|
||||
// Follows the same choices as IrisBiome#getSkyBiome and #getCustomBiome, returning null when chunks can differ.
|
||||
private BiomeTintColors onlyPossibleColors() {
|
||||
if (biome.isCustom()) {
|
||||
KList<IrisBiomeCustom> variants = biome.getCustomDerivitives();
|
||||
return variants.size() == 1 ? customColors(variants.get(0)) : null;
|
||||
}
|
||||
KList<Biome> skyBiomes = biome.getBiomeSkyScatter();
|
||||
if (!skyBiomes.isEmpty()) {
|
||||
return skyBiomes.size() == 1 ? vanillaColors(skyBiomes.get(0)) : null;
|
||||
}
|
||||
KList<Biome> groundBiomes = biome.getBiomeScatter();
|
||||
if (groundBiomes.isEmpty()) {
|
||||
return vanillaColors(biome.getDerivative());
|
||||
}
|
||||
return groundBiomes.size() == 1 ? vanillaColors(groundBiomes.get(0)) : null;
|
||||
}
|
||||
|
||||
private BiomeTintColors customColors(IrisBiomeCustom customBiome) {
|
||||
return colorsByCustomBiome.computeIfAbsent(customBiome, minecraftColors::custom);
|
||||
}
|
||||
|
||||
private BiomeTintColors vanillaColors(Biome vanillaBiome) {
|
||||
return minecraftColors.vanilla(vanillaBiome.getKey());
|
||||
}
|
||||
|
||||
private static int cacheSlot(int x, int z, int salt, int cacheSize) {
|
||||
long hash = x * 0x9E3779B97F4A7C15L ^ z * 0xC2B2AE3D27D4EB4FL ^ salt * 0x165667B19E3779F9L;
|
||||
return (int) ((hash ^ (hash >>> 29)) & (cacheSize - 1));
|
||||
}
|
||||
|
||||
private record BlendedTint(int x, int z, BiomeTint tint, int color) {
|
||||
}
|
||||
|
||||
private record ChunkColors(int chunkX, int chunkZ, BiomeTintColors colors) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.BlockTextures.BlockTexture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* A block as the object viewer draws it: the textured faces of its model, in block units, already turned the way the
|
||||
* blockstate faces.
|
||||
*
|
||||
* @param faces every face of every box in the model
|
||||
* @param coveredSides the block's sides that a solid face fills completely, which hide whatever touches them
|
||||
* @param hidesSameBlockSides whether the model fills the whole block, so like glass it hides the sides between two of
|
||||
* the same blocks
|
||||
*/
|
||||
record BlockModel(List<Face> faces, Set<CubeFace> coveredSides, boolean hidesSameBlockSides) {
|
||||
static final BlockModel EMPTY = new BlockModel(List.of(), Set.of(), false);
|
||||
private static final int SIDE_CELLS = 16;
|
||||
private static final double ON_SIDE_TOLERANCE = 1e-4D;
|
||||
|
||||
static BlockModel of(List<Face> faces) {
|
||||
Set<CubeFace> coveredSides = EnumSet.noneOf(CubeFace.class);
|
||||
boolean fillsEverySide = true;
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
if (sideIsFilled(faces, side, true)) {
|
||||
coveredSides.add(side);
|
||||
}
|
||||
fillsEverySide &= sideIsFilled(faces, side, false);
|
||||
}
|
||||
return new BlockModel(List.copyOf(faces), Set.copyOf(coveredSides), fillsEverySide);
|
||||
}
|
||||
|
||||
static BlockModel solidColor(int color) {
|
||||
BlockTexture texture = BlockTexture.solid(color);
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
faces.add(Face.boxSide(Vec3.ZERO, new Vec3(1D, 1D, 1D), side, texture, side));
|
||||
}
|
||||
return of(faces);
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return faces.isEmpty();
|
||||
}
|
||||
|
||||
BlockModel withTextures(UnaryOperator<BlockTexture> change) {
|
||||
Map<BlockTexture, BlockTexture> changed = new IdentityHashMap<>();
|
||||
List<Face> changedFaces = new ArrayList<>(faces.size());
|
||||
for (Face face : faces) {
|
||||
changedFaces.add(face.withTexture(changed.computeIfAbsent(face.texture(), change)));
|
||||
}
|
||||
return new BlockModel(List.copyOf(changedFaces), coveredSides, hidesSameBlockSides);
|
||||
}
|
||||
|
||||
// The side is split into a grid, and every cell has to lie under a face pressed flat against it.
|
||||
private static boolean sideIsFilled(List<Face> faces, CubeFace side, boolean onlySolidFaces) {
|
||||
boolean[] filled = new boolean[SIDE_CELLS * SIDE_CELLS];
|
||||
for (Face face : faces) {
|
||||
if (face.liesOn(side) && (!onlySolidFaces || face.texture().isFullyOpaque())) {
|
||||
face.fillCells(side, filled, SIDE_CELLS);
|
||||
}
|
||||
}
|
||||
for (boolean cell : filled) {
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parallelogram from {@code origin} along {@code edgeU} and {@code edgeV}. The texture point at the origin and
|
||||
* how it moves along each edge are given as fractions of the texture.
|
||||
*
|
||||
* @param normal the way the face looks out; it is hidden when seen from behind
|
||||
* @param cullFace the block side whose neighbour hides this face when it covers that side, or null
|
||||
*/
|
||||
record Face(Vec3 origin, Vec3 edgeU, Vec3 edgeV, Vec3 normal, BlockTexture texture, TextureMapping mapping,
|
||||
double shade, CubeFace cullFace) {
|
||||
|
||||
// Laid out as seen from outside: u to the right and v downwards, matching Minecraft's UVs for a whole texture.
|
||||
static Face boxSide(Vec3 from, Vec3 to, CubeFace side, BlockTexture texture, CubeFace cullFace) {
|
||||
Vec3[] corners = BoxCorners.of(from, to, side);
|
||||
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
|
||||
texture, TextureMapping.WHOLE, side.shade(), cullFace);
|
||||
}
|
||||
|
||||
Face withTexture(BlockTexture changedTexture) {
|
||||
return new Face(origin, edgeU, edgeV, normal, changedTexture, mapping, shade, cullFace);
|
||||
}
|
||||
|
||||
Face transformed(Transform transform) {
|
||||
Vec3 turnedNormal = transform.direction(normal);
|
||||
double length = Math.sqrt(turnedNormal.x() * turnedNormal.x() + turnedNormal.y() * turnedNormal.y() + turnedNormal.z() * turnedNormal.z());
|
||||
return new Face(transform.point(origin), transform.direction(edgeU), transform.direction(edgeV),
|
||||
length == 0D ? turnedNormal : turnedNormal.times(1D / length), texture, mapping, shade,
|
||||
cullFace == null ? null : CubeFace.nearest(transform.direction(cullFace.direction())));
|
||||
}
|
||||
|
||||
Face withCullFace(CubeFace side) {
|
||||
return new Face(origin, edgeU, edgeV, normal, texture, mapping, shade, side);
|
||||
}
|
||||
|
||||
Face withShade(double changedShade) {
|
||||
return new Face(origin, edgeU, edgeV, normal, texture, mapping, changedShade, cullFace);
|
||||
}
|
||||
|
||||
private boolean liesOn(CubeFace side) {
|
||||
int axis = side.axis();
|
||||
double plane = side.pointsPositive() ? 1D : 0D;
|
||||
return CubeFace.nearest(normal) == side
|
||||
&& Math.abs(normal.component(axis)) > 1D - ON_SIDE_TOLERANCE
|
||||
&& Math.abs(origin.component(axis) - plane) < ON_SIDE_TOLERANCE
|
||||
&& Math.abs(edgeU.component(axis)) < ON_SIDE_TOLERANCE
|
||||
&& Math.abs(edgeV.component(axis)) < ON_SIDE_TOLERANCE;
|
||||
}
|
||||
|
||||
private void fillCells(CubeFace side, boolean[] filled, int cells) {
|
||||
int firstAxis = side.axis() == 0 ? 1 : 0;
|
||||
int secondAxis = side.axis() == 2 ? 1 : 2;
|
||||
double[] firstRange = range(firstAxis);
|
||||
double[] secondRange = range(secondAxis);
|
||||
for (int first = 0; first < cells; first++) {
|
||||
double firstCenter = (first + 0.5D) / cells;
|
||||
if (firstCenter < firstRange[0] || firstCenter > firstRange[1]) {
|
||||
continue;
|
||||
}
|
||||
for (int second = 0; second < cells; second++) {
|
||||
double secondCenter = (second + 0.5D) / cells;
|
||||
if (secondCenter >= secondRange[0] && secondCenter <= secondRange[1]) {
|
||||
filled[first * cells + second] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double[] range(int axis) {
|
||||
double start = origin.component(axis);
|
||||
double alongU = edgeU.component(axis);
|
||||
double alongV = edgeV.component(axis);
|
||||
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
|
||||
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
|
||||
return new double[]{low, high};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a face's corner sits in its texture and how the texture point moves along the face's two edges, all as
|
||||
* fractions of the texture's width and height.
|
||||
*/
|
||||
record TextureMapping(double u, double v, double uAlongU, double vAlongU, double uAlongV, double vAlongV) {
|
||||
static final TextureMapping WHOLE = new TextureMapping(0D, 0D, 1D, 0D, 0D, 1D);
|
||||
}
|
||||
|
||||
/**
|
||||
* A box side's corners in Minecraft's order: top left, bottom left, bottom right and top right, as seen from
|
||||
* outside the box.
|
||||
*/
|
||||
static final class BoxCorners {
|
||||
private BoxCorners() {
|
||||
}
|
||||
|
||||
static Vec3[] of(Vec3 from, Vec3 to, CubeFace side) {
|
||||
double minX = from.x();
|
||||
double minY = from.y();
|
||||
double minZ = from.z();
|
||||
double maxX = to.x();
|
||||
double maxY = to.y();
|
||||
double maxZ = to.z();
|
||||
return switch (side) {
|
||||
case DOWN -> corners(minX, minY, maxZ, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ);
|
||||
case UP -> corners(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ);
|
||||
case NORTH -> corners(maxX, maxY, minZ, maxX, minY, minZ, minX, minY, minZ, minX, maxY, minZ);
|
||||
case SOUTH -> corners(minX, maxY, maxZ, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ);
|
||||
case WEST -> corners(minX, maxY, minZ, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ);
|
||||
case EAST -> corners(maxX, maxY, maxZ, maxX, minY, maxZ, maxX, minY, minZ, maxX, maxY, minZ);
|
||||
};
|
||||
}
|
||||
|
||||
private static Vec3[] corners(double... coordinates) {
|
||||
Vec3[] corners = new Vec3[4];
|
||||
for (int corner = 0; corner < 4; corner++) {
|
||||
corners[corner] = new Vec3(coordinates[corner * 3], coordinates[corner * 3 + 1], coordinates[corner * 3 + 2]);
|
||||
}
|
||||
return corners;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* 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.volmit.iris.core.gui.BlockModel.BoxCorners;
|
||||
import com.volmit.iris.core.gui.BlockModel.Face;
|
||||
import com.volmit.iris.core.gui.BlockModel.TextureMapping;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockStateId;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.core.gui.BlockTextures.ModelDefinition;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads a block's look the way Minecraft does: every model part the blockstate picks, every box with its own texture
|
||||
* and UVs, tilted boxes, the blockstate's turn with or without locked textures, and which faces a neighbour hides.
|
||||
*/
|
||||
final class BlockModelBuilder {
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final double BLOCK_CENTER = 0.5D;
|
||||
private static final int QUARTER_TURN = 90;
|
||||
|
||||
private final BlockTextures textures;
|
||||
private final EntityBlockShapes entityShapes;
|
||||
|
||||
BlockModelBuilder(BlockTextures textures) {
|
||||
this.textures = textures;
|
||||
this.entityShapes = new EntityBlockShapes(textures);
|
||||
}
|
||||
|
||||
Optional<BlockModel> build(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
List<JsonObject> placements = placementsOf(state);
|
||||
if (placements == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (JsonObject placement : placements) {
|
||||
faces.addAll(placedFaces(state, placement));
|
||||
}
|
||||
faces.addAll(entityShapes.facesAddedTo(state));
|
||||
return Optional.of(BlockModel.of(faces));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A box with Minecraft's default UVs on each side, for shapes the game draws in code.
|
||||
*/
|
||||
static List<Face> box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) {
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
FaceUv uv = FaceUv.byDefault(side, fromPixels, toPixels);
|
||||
Face face = boxFace(fromPixels, toPixels, side, texture, uv).withShade(side.shade());
|
||||
faces.add(culledBySides ? face.withCullFace(side) : face);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A variant names one model; a multipart blockstate, like a fence's, adds a part for every condition that holds.
|
||||
private List<JsonObject> placementsOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
if (definition == null) {
|
||||
return null;
|
||||
}
|
||||
List<JsonObject> placements = new ArrayList<>();
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
|
||||
if (BlockTextures.variantMatches(variant.getKey(), state.properties())) {
|
||||
addPlacement(placements, variant.getValue());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (definition.has("multipart")) {
|
||||
for (JsonElement part : definition.getAsJsonArray("multipart")) {
|
||||
JsonObject rule = part.getAsJsonObject();
|
||||
if (!rule.has("when") || BlockTextures.conditionMatches(rule.getAsJsonObject("when"), state.properties())) {
|
||||
addPlacement(placements, rule.get("apply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
private static void addPlacement(List<JsonObject> placements, JsonElement models) {
|
||||
JsonObject first = BlockTextures.firstModel(models);
|
||||
if (first != null && first.has("model")) {
|
||||
placements.add(first);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Face> placedFaces(BlockStateId state, JsonObject placement) throws IOException {
|
||||
ModelDefinition model = textures.readModel(placement.get("model").getAsString());
|
||||
if (model.elements() == null) {
|
||||
return entityShapes.facesOf(state, model);
|
||||
}
|
||||
BlockRotation rotation = BlockRotation.of(placement);
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (JsonElement element : model.elements()) {
|
||||
addElementFaces(faces, state, model, element.getAsJsonObject(), rotation);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
private void addElementFaces(List<Face> faces, BlockStateId state, ModelDefinition model, JsonObject element, BlockRotation rotation) {
|
||||
JsonObject elementFaces = element.getAsJsonObject("faces");
|
||||
if (elementFaces == null) {
|
||||
return;
|
||||
}
|
||||
Vec3 from = vector(element.getAsJsonArray("from"));
|
||||
Vec3 to = vector(element.getAsJsonArray("to"));
|
||||
Transform tilt = tiltOf(element);
|
||||
boolean shaded = !element.has("shade") || element.get("shade").getAsBoolean();
|
||||
CubeFace shadeSide = element.has("shade_direction_override")
|
||||
? CubeFace.named(element.get("shade_direction_override").getAsString()) : null;
|
||||
for (Map.Entry<String, JsonElement> entry : elementFaces.entrySet()) {
|
||||
CubeFace side = CubeFace.named(entry.getKey());
|
||||
JsonObject faceJson = entry.getValue().getAsJsonObject();
|
||||
if (side == null || !faceJson.has("texture")) {
|
||||
continue;
|
||||
}
|
||||
String textureName = BlockTextures.dereference(faceJson.get("texture").getAsString(), model.textures());
|
||||
if (textureName == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.faceTexture(state, textureName, BlockTextures.tintIndexOf(faceJson));
|
||||
if (texture.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
FaceUv uv = FaceUv.of(faceJson, side, from, to);
|
||||
if (rotation.locksTextures()) {
|
||||
uv = uv.lockedTo(side, rotation);
|
||||
}
|
||||
Face face = boxFace(from, to, side, texture.get(), uv).transformed(tilt);
|
||||
if (faceJson.has("cullface")) {
|
||||
face = face.withCullFace(CubeFace.named(faceJson.get("cullface").getAsString()));
|
||||
}
|
||||
face = face.transformed(rotation.transform());
|
||||
faces.add(face.withShade(shadeOf(face, shaded, shadeSide, rotation)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Face boxFace(Vec3 fromPixels, Vec3 toPixels, CubeFace side, BlockTexture texture, FaceUv uv) {
|
||||
Vec3[] corners = BoxCorners.of(fromPixels.times(1D / MODEL_UNITS), toPixels.times(1D / MODEL_UNITS), side);
|
||||
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
|
||||
texture, uv.mapping(), side.shade(), null);
|
||||
}
|
||||
|
||||
private static double shadeOf(Face face, boolean shaded, CubeFace shadeSide, BlockRotation rotation) {
|
||||
if (!shaded) {
|
||||
return CubeFace.UP.shade();
|
||||
}
|
||||
if (shadeSide != null) {
|
||||
return CubeFace.nearest(rotation.transform().direction(shadeSide.direction())).shade();
|
||||
}
|
||||
return CubeFace.nearest(face.normal()).shade();
|
||||
}
|
||||
|
||||
// A box can be tilted around a point, on one axis or, since 1.21.?, on all three; rescaling stretches a tilted
|
||||
// plant back to the full block width.
|
||||
private static Transform tiltOf(JsonObject element) {
|
||||
if (!element.has("rotation")) {
|
||||
return Transform.IDENTITY;
|
||||
}
|
||||
JsonObject rotation = element.getAsJsonObject("rotation");
|
||||
Vec3 origin = rotation.has("origin") ? vector(rotation.getAsJsonArray("origin")).times(1D / MODEL_UNITS) : new Vec3(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER);
|
||||
Transform around = Transform.IDENTITY.translate(origin.x(), origin.y(), origin.z());
|
||||
Transform back = Transform.IDENTITY.translate(-origin.x(), -origin.y(), -origin.z());
|
||||
if (!rotation.has("axis")) {
|
||||
return around
|
||||
.rotateX(numberOr(rotation, "x", 0D))
|
||||
.rotateY(numberOr(rotation, "y", 0D))
|
||||
.rotateZ(numberOr(rotation, "z", 0D))
|
||||
.then(back);
|
||||
}
|
||||
String axis = rotation.get("axis").getAsString();
|
||||
double angle = numberOr(rotation, "angle", 0D);
|
||||
boolean rescale = rotation.has("rescale") && rotation.get("rescale").getAsBoolean();
|
||||
double stretch = rescale ? 1D / Math.cos(Math.toRadians(angle)) : 1D;
|
||||
Transform stretched = around.scale(axis.equals("x") ? 1D : stretch, axis.equals("y") ? 1D : stretch, axis.equals("z") ? 1D : stretch);
|
||||
Transform tilted = switch (axis) {
|
||||
case "x" -> stretched.rotateX(angle);
|
||||
case "y" -> stretched.rotateY(angle);
|
||||
default -> stretched.rotateZ(angle);
|
||||
};
|
||||
return tilted.then(back);
|
||||
}
|
||||
|
||||
private static double numberOr(JsonObject object, String key, double fallback) {
|
||||
return object.has(key) ? object.get(key).getAsDouble() : fallback;
|
||||
}
|
||||
|
||||
private static Vec3 vector(JsonArray values) {
|
||||
return new Vec3(values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble());
|
||||
}
|
||||
|
||||
/**
|
||||
* How a blockstate turns its model: around the x axis first, then the y axis, both in quarter turns against the
|
||||
* right-hand rule. With uvlock the textures stay put while the shape turns.
|
||||
*/
|
||||
private record BlockRotation(int x, int y, boolean uvlock) {
|
||||
static BlockRotation of(JsonObject placement) {
|
||||
int x = placement.has("x") ? Math.floorMod(placement.get("x").getAsInt(), 360) : 0;
|
||||
int y = placement.has("y") ? Math.floorMod(placement.get("y").getAsInt(), 360) : 0;
|
||||
return new BlockRotation(x, y, placement.has("uvlock") && placement.get("uvlock").getAsBoolean());
|
||||
}
|
||||
|
||||
boolean locksTextures() {
|
||||
return uvlock && (x != 0 || y != 0);
|
||||
}
|
||||
|
||||
Transform transform() {
|
||||
return Transform.IDENTITY
|
||||
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
|
||||
.then(turn())
|
||||
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
|
||||
}
|
||||
|
||||
Transform turn() {
|
||||
return Transform.IDENTITY.rotateY(-y).rotateX(-x);
|
||||
}
|
||||
|
||||
Transform inverseTurn() {
|
||||
return Transform.IDENTITY.rotateX(x).rotateY(y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A face's corner UVs in model pixels and the quarter turn of the texture on it.
|
||||
*/
|
||||
private record FaceUv(double u1, double v1, double u2, double v2, int rotation) {
|
||||
static FaceUv of(JsonObject face, CubeFace side, Vec3 from, Vec3 to) {
|
||||
int rotation = face.has("rotation") ? Math.floorMod(face.get("rotation").getAsInt(), 360) : 0;
|
||||
if (!face.has("uv")) {
|
||||
return byDefault(side, from, to).withRotation(rotation);
|
||||
}
|
||||
JsonArray uv = face.getAsJsonArray("uv");
|
||||
return new FaceUv(uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble(), rotation);
|
||||
}
|
||||
|
||||
// The part of the texture the face would cover if the texture were laid over the whole block side.
|
||||
static FaceUv byDefault(CubeFace side, Vec3 from, Vec3 to) {
|
||||
double full = MODEL_UNITS;
|
||||
return switch (side) {
|
||||
case DOWN -> new FaceUv(from.x(), full - to.z(), to.x(), full - from.z(), 0);
|
||||
case UP -> new FaceUv(from.x(), from.z(), to.x(), to.z(), 0);
|
||||
case NORTH -> new FaceUv(full - to.x(), full - to.y(), full - from.x(), full - from.y(), 0);
|
||||
case SOUTH -> new FaceUv(from.x(), full - to.y(), to.x(), full - from.y(), 0);
|
||||
case WEST -> new FaceUv(from.z(), full - to.y(), to.z(), full - from.y(), 0);
|
||||
case EAST -> new FaceUv(full - to.z(), full - to.y(), full - from.z(), full - from.y(), 0);
|
||||
};
|
||||
}
|
||||
|
||||
FaceUv withRotation(int changedRotation) {
|
||||
return new FaceUv(u1, v1, u2, v2, changedRotation);
|
||||
}
|
||||
|
||||
// Each corner takes the UV a quarter turn further along for every quarter turn of the texture.
|
||||
TextureMapping mapping() {
|
||||
double[] origin = cornerUv(0);
|
||||
double[] alongU = cornerUv(3);
|
||||
double[] alongV = cornerUv(1);
|
||||
return new TextureMapping(origin[0] / MODEL_UNITS, origin[1] / MODEL_UNITS,
|
||||
(alongU[0] - origin[0]) / MODEL_UNITS, (alongU[1] - origin[1]) / MODEL_UNITS,
|
||||
(alongV[0] - origin[0]) / MODEL_UNITS, (alongV[1] - origin[1]) / MODEL_UNITS);
|
||||
}
|
||||
|
||||
private double[] cornerUv(int corner) {
|
||||
return switch ((corner + rotation / QUARTER_TURN) % 4) {
|
||||
case 0 -> new double[]{u1, v1};
|
||||
case 1 -> new double[]{u1, v2};
|
||||
case 2 -> new double[]{u2, v2};
|
||||
default -> new double[]{u2, v1};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minecraft's uvlock: the UVs are carried through the reverse of the blockstate's turn, so the texture keeps
|
||||
* its place in the world while the face moves to another side.
|
||||
*/
|
||||
FaceUv lockedTo(CubeFace side, BlockRotation blockRotation) {
|
||||
CubeFace turnedSide = CubeFace.nearest(blockRotation.turn().direction(side.direction()));
|
||||
Transform uvTurn = Transform.IDENTITY
|
||||
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
|
||||
.then(faceFromWorld(side))
|
||||
.then(blockRotation.inverseTurn())
|
||||
.then(worldFromFace(turnedSide))
|
||||
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
|
||||
Vec3 first = uvTurn.point(new Vec3(u1 / MODEL_UNITS, v1 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
|
||||
Vec3 second = uvTurn.point(new Vec3(u2 / MODEL_UNITS, v2 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
|
||||
boolean keepsUOrder = Math.signum(u2 - u1) == Math.signum(second.x() - first.x());
|
||||
boolean keepsVOrder = Math.signum(v2 - v1) == Math.signum(second.y() - first.y());
|
||||
Vec3 textureRight = uvTurn.direction(new Vec3(Math.cos(Math.toRadians(rotation)), Math.sin(Math.toRadians(rotation)), 0D));
|
||||
long quarterTurns = Math.round(Math.toDegrees(Math.atan2(textureRight.y(), textureRight.x())) / QUARTER_TURN);
|
||||
return new FaceUv(
|
||||
keepsUOrder ? first.x() : second.x(), keepsVOrder ? first.y() : second.y(),
|
||||
keepsUOrder ? second.x() : first.x(), keepsVOrder ? second.y() : first.y(),
|
||||
(int) Math.floorMod(-quarterTurns * QUARTER_TURN, 360L));
|
||||
}
|
||||
|
||||
// Minecraft lays every side's texture out as if it were the south side, turned into place.
|
||||
private static Transform worldFromFace(CubeFace side) {
|
||||
return switch (side) {
|
||||
case SOUTH -> Transform.IDENTITY;
|
||||
case EAST -> Transform.IDENTITY.rotateY(90D);
|
||||
case WEST -> Transform.IDENTITY.rotateY(-90D);
|
||||
case NORTH -> Transform.IDENTITY.rotateY(180D);
|
||||
case UP -> Transform.IDENTITY.rotateX(-90D);
|
||||
case DOWN -> Transform.IDENTITY.rotateX(90D);
|
||||
};
|
||||
}
|
||||
|
||||
private static Transform faceFromWorld(CubeFace side) {
|
||||
return switch (side) {
|
||||
case SOUTH -> Transform.IDENTITY;
|
||||
case EAST -> Transform.IDENTITY.rotateY(-90D);
|
||||
case WEST -> Transform.IDENTITY.rotateY(90D);
|
||||
case NORTH -> Transform.IDENTITY.rotateY(180D);
|
||||
case UP -> Transform.IDENTITY.rotateX(90D);
|
||||
case DOWN -> Transform.IDENTITY.rotateX(-90D);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class BlockPreviewColors {
|
||||
private static final Method MAP_COLOR_METHOD = findMapColorMethod();
|
||||
private static final double[] MAP_SHADES = {1D, 0.86D, 0.71D, 0.53D};
|
||||
private static final int NO_MAP_COLOR = 0;
|
||||
|
||||
private BlockPreviewColors() {
|
||||
}
|
||||
|
||||
static Map<String, Integer> colorsByBlock(List<BlockData> blocks) {
|
||||
Map<String, Integer> colors = new HashMap<>();
|
||||
List<Integer> usedColors = new ArrayList<>();
|
||||
for (BlockData block : blocks) {
|
||||
String key = block.getAsString(true);
|
||||
if (colors.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
int baseColor = baseColor(block);
|
||||
int color = firstUnusedShade(baseColor, usedColors);
|
||||
usedColors.add(color);
|
||||
colors.put(key, color);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Blocks that share a map colour, like sand and sandstone, get darker shades the way Minecraft maps shade them.
|
||||
private static int firstUnusedShade(int baseColor, List<Integer> usedColors) {
|
||||
for (double shade : MAP_SHADES) {
|
||||
int shaded = shade(baseColor, shade);
|
||||
if (!usedColors.contains(shaded)) {
|
||||
return shaded;
|
||||
}
|
||||
}
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
private static int baseColor(BlockData block) {
|
||||
int mapColor = mapColor(block);
|
||||
return mapColor == NO_MAP_COLOR ? colorFromName(block.getMaterial().getKey().getKey()) : mapColor;
|
||||
}
|
||||
|
||||
private static int mapColor(BlockData block) {
|
||||
if (MAP_COLOR_METHOD == null) {
|
||||
return NO_MAP_COLOR;
|
||||
}
|
||||
try {
|
||||
return ((org.bukkit.Color) MAP_COLOR_METHOD.invoke(block)).asRGB();
|
||||
} catch (ReflectiveOperationException | RuntimeException error) {
|
||||
return NO_MAP_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
private static int colorFromName(String name) {
|
||||
float hue = (name.hashCode() & 0xFFFF) / (float) 0xFFFF;
|
||||
return Color.HSBtoRGB(hue, 0.45F, 0.75F) & 0xFFFFFF;
|
||||
}
|
||||
|
||||
static int shade(int color, double shade) {
|
||||
int red = (int) Math.min(255, ((color >>> 16) & 0xFF) * shade);
|
||||
int green = (int) Math.min(255, ((color >>> 8) & 0xFF) * shade);
|
||||
int blue = (int) Math.min(255, (color & 0xFF) * shade);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
// Iris compiles against the 1.20.1 API, which predates BlockData#getMapColor.
|
||||
private static Method findMapColorMethod() {
|
||||
try {
|
||||
return BlockData.class.getMethod("getMapColor");
|
||||
} catch (NoSuchMethodException error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
/*
|
||||
* 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 org.bukkit.block.data.Bisected;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
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;
|
||||
static final int NO_TINT_INDEX = -1;
|
||||
private static final Object SHARED_LOCK = new Object();
|
||||
private static CompletableFuture<BlockTextures> shared;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final MinecraftBiomeColors biomeColors;
|
||||
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockTexture>> faceTextures = new ConcurrentHashMap<>();
|
||||
private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
|
||||
|
||||
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
|
||||
this.clientJar = clientJar;
|
||||
this.biomeColors = biomeColors;
|
||||
}
|
||||
|
||||
static CompletableFuture<BlockTextures> shared() {
|
||||
synchronized (SHARED_LOCK) {
|
||||
if (shared == null || shared.isCompletedExceptionally()) {
|
||||
shared = MinecraftClientJar.open().thenApply(BlockTextures::load);
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockTextures load(ZipFile clientJar) {
|
||||
try {
|
||||
return new BlockTextures(clientJar, MinecraftBiomeColors.load(clientJar));
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Could not read Minecraft's biome colour maps", error);
|
||||
}
|
||||
}
|
||||
|
||||
MinecraftBiomeColors biomeColors() {
|
||||
return biomeColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the block looks like from above. Plants and other side-on sprites become their average colour, because
|
||||
* their sprite would read as stripes across a stack of ground blocks.
|
||||
*/
|
||||
Optional<BlockTexture> topTexture(BlockData block) {
|
||||
return resolve(block).map(resolved -> resolved.isSideOnSprite() ? resolved.texture().flattened() : resolved.texture());
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
|
||||
* Two block tall plants give their bottom half, the part standing on the ground.
|
||||
*/
|
||||
Optional<BlockTexture> iconTexture(BlockData block) {
|
||||
BlockData standing = block instanceof Bisected ? withHalf(block, Bisected.Half.BOTTOM) : block;
|
||||
return resolve(standing).map(ResolvedTexture::texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sprite of a two block tall plant's top half, or nothing for any other block.
|
||||
*/
|
||||
Optional<BlockTexture> upperHalfIcon(BlockData block) {
|
||||
if (!(block instanceof Bisected)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return resolve(withHalf(block, Bisected.Half.TOP)).filter(ResolvedTexture::isSideOnSprite).map(ResolvedTexture::texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the block looks like from every side, for drawing it in 3D.
|
||||
*/
|
||||
Optional<BlockModel> model(BlockData block) {
|
||||
return resolvedModels.computeIfAbsent(block.getAsString(false), modelBuilder::build);
|
||||
}
|
||||
|
||||
private static BlockData withHalf(BlockData block, Bisected.Half half) {
|
||||
BlockData copy = block.clone();
|
||||
((Bisected) copy).setHalf(half);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Blockstate files match on every property, so defaults the pack left out have to be spelled out too.
|
||||
private Optional<ResolvedTexture> resolve(BlockData block) {
|
||||
return resolvedTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture);
|
||||
}
|
||||
|
||||
private Optional<ResolvedTexture> resolveTopTexture(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
JsonObject variant = variantOf(state);
|
||||
if (variant == null || !variant.has("model")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ModelFace face = readModel(variant.get("model").getAsString()).visibleFace(faceSeenFromAbove(variant));
|
||||
if (face == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return readTexture(state, face).map(texture -> new ResolvedTexture(texture, face.isSideOnSprite()));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject variantOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
return definition == null ? null : pickVariant(definition, state.properties());
|
||||
}
|
||||
|
||||
private static int rotationOf(JsonObject variant, String axis) {
|
||||
return variant.has(axis) ? Math.floorMod(variant.get(axis).getAsInt(), 360) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* One model face's texture with the block's tint, shared by every block state that uses it.
|
||||
*/
|
||||
Optional<BlockTexture> faceTexture(BlockStateId state, String texture, int tintIndex) {
|
||||
int fixedTint = MinecraftBlockTints.fixedTint(state.name(), state.properties(), tintIndex);
|
||||
BiomeTint biomeTint = MinecraftBlockTints.biomeTint(state.name(), tintIndex).orElse(null);
|
||||
return faceTextures.computeIfAbsent(texture + "|" + fixedTint + "|" + biomeTint, key -> {
|
||||
try {
|
||||
return readTexture(state, new ModelFace(List.of(texture), tintIndex, false));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture drawn on a block entity, like a chest, whole and tinted with a fixed colour.
|
||||
*/
|
||||
Optional<BlockTexture> entityTexture(String texture, int tint) {
|
||||
return faceTextures.computeIfAbsent(texture + "|entity|" + tint, key -> {
|
||||
try {
|
||||
BufferedImage image = readImage(assetPath(texture, "textures", ".png"));
|
||||
return image == null ? Optional.empty() : Optional.of(BlockTexture.fromWholeImage(image, tint));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<BlockTexture> readTexture(BlockStateId state, ModelFace face) throws IOException {
|
||||
BufferedImage image = readLayeredImage(face.textures());
|
||||
if (image == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(BlockTexture.fromImage(
|
||||
image,
|
||||
MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()),
|
||||
MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null)
|
||||
));
|
||||
} catch (IllegalArgumentException invisibleTexture) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// A block rotated on its x axis, like a sideways log, shows a side face on top. Blockstates turn the model
|
||||
// against the right-hand rule, so 90 degrees brings the south side up.
|
||||
private static String faceSeenFromAbove(JsonObject variant) {
|
||||
return switch (rotationOf(variant, "x")) {
|
||||
case 90 -> "south";
|
||||
case 270 -> "north";
|
||||
case 180 -> "down";
|
||||
default -> "up";
|
||||
};
|
||||
}
|
||||
|
||||
ModelDefinition readModel(String modelReference) 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(), spriteName(texture.getValue()));
|
||||
}
|
||||
}
|
||||
if (elements == null && model.has("elements")) {
|
||||
elements = model.getAsJsonArray("elements");
|
||||
}
|
||||
current = model.has("parent") ? model.get("parent").getAsString() : null;
|
||||
}
|
||||
return new ModelDefinition(textures, elements);
|
||||
}
|
||||
|
||||
// Since 1.21.? a texture can also be an object naming its sprite, like glass's {"sprite": ..., "force_translucent": true}.
|
||||
private static String spriteName(JsonElement texture) {
|
||||
return texture.isJsonObject() ? texture.getAsJsonObject().get("sprite").getAsString() : texture.getAsString();
|
||||
}
|
||||
|
||||
// Some plants draw a second sprite over the first, like the glowing fireflies on a firefly bush.
|
||||
private static List<String> spriteLayers(JsonArray elements, Map<String, String> textures) {
|
||||
Set<String> layers = new LinkedHashSet<>();
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
|
||||
if (faces == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
|
||||
String texture = dereference(face.getValue().getAsJsonObject().get("texture").getAsString(), textures);
|
||||
if (texture != null) {
|
||||
layers.add(texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(layers);
|
||||
}
|
||||
|
||||
private static int firstTintIndex(JsonArray elements) {
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
|
||||
if (faces == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
|
||||
int tintIndex = tintIndexOf(face.getValue().getAsJsonObject());
|
||||
if (tintIndex != NO_TINT_INDEX) {
|
||||
return tintIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
return NO_TINT_INDEX;
|
||||
}
|
||||
|
||||
static int tintIndexOf(JsonObject face) {
|
||||
return face.has("tintindex") ? face.get("tintindex").getAsInt() : NO_TINT_INDEX;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static JsonObject firstModel(JsonElement models) {
|
||||
if (models.isJsonArray()) {
|
||||
JsonArray choices = models.getAsJsonArray();
|
||||
return choices.isEmpty() ? null : choices.get(0).getAsJsonObject();
|
||||
}
|
||||
return models.getAsJsonObject();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
JsonObject readJson(String path) throws IOException {
|
||||
return ClientJarFiles.readJson(clientJar, path);
|
||||
}
|
||||
|
||||
// Each layer is drawn over the ones before it, using only its first frame when it is animated.
|
||||
private BufferedImage readLayeredImage(List<String> layers) throws IOException {
|
||||
BufferedImage bottom = readImage(assetPath(layers.get(0), "textures", ".png"));
|
||||
if (bottom == null || layers.size() == 1) {
|
||||
return bottom;
|
||||
}
|
||||
int size = bottom.getWidth();
|
||||
BufferedImage combined = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D canvas = combined.createGraphics();
|
||||
try {
|
||||
for (String layer : layers) {
|
||||
BufferedImage image = readImage(assetPath(layer, "textures", ".png"));
|
||||
if (image != null) {
|
||||
int frameSize = image.getWidth();
|
||||
canvas.drawImage(image, 0, 0, size, size, 0, 0, frameSize, frameSize, null);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
private BufferedImage readImage(String path) throws IOException {
|
||||
return ClientJarFiles.readImage(clientJar, path);
|
||||
}
|
||||
|
||||
// Plants and other blocks without a top face only have a side-on sprite, which would read as stripes from above.
|
||||
private record ModelFace(List<String> textures, int tintIndex, boolean isSideOnSprite) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A block model with its parents' textures merged in. Models drawn in code, like chests, have no elements.
|
||||
*/
|
||||
record ModelDefinition(Map<String, String> textures, JsonArray elements) {
|
||||
ModelFace visibleFace(String faceName) {
|
||||
ModelFace face = sideFace(faceName);
|
||||
if (face != null) {
|
||||
return face;
|
||||
}
|
||||
return elements != null && !spriteLayers(elements, textures).isEmpty() ? spriteFace() : particleFace();
|
||||
}
|
||||
|
||||
// The face's texture on the model's topmost box, the particle texture for models without boxes, or null when
|
||||
// the model leaves that side open.
|
||||
ModelFace sideFace(String faceName) {
|
||||
if (elements == null) {
|
||||
return particleFace();
|
||||
}
|
||||
JsonObject face = topmostFace(elements, faceName);
|
||||
if (face == null) {
|
||||
return null;
|
||||
}
|
||||
String texture = dereference(face.get("texture").getAsString(), textures);
|
||||
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
|
||||
}
|
||||
|
||||
ModelFace spriteFace() {
|
||||
return new ModelFace(spriteLayers(elements, textures), firstTintIndex(elements), true);
|
||||
}
|
||||
|
||||
private ModelFace particleFace() {
|
||||
String particle = dereference("#particle", textures);
|
||||
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) {
|
||||
}
|
||||
|
||||
record BlockStateId(String namespace, String name, Map<String, String> properties) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Biome tinted textures keep their grey pixels, because the biome colour changes from column to column.
|
||||
// See-through pixels get a darker shade of the average colour, and the opaque mask remembers which they were.
|
||||
// Textures with partly see-through pixels, like stained glass, ice and water, keep each pixel's alpha too.
|
||||
record BlockTexture(int width, int height, int[] pixels, boolean[] opaque, int[] alphas, int averageColor,
|
||||
BiomeTint biomeTint) {
|
||||
private static final double GAP_SHADE = 0.55D;
|
||||
private static final int FULL_ALPHA = 255;
|
||||
|
||||
// Animated textures stack their frames vertically, so only the first square frame is used.
|
||||
static BlockTexture fromImage(BufferedImage image, int fixedTint, BiomeTint biomeTint) {
|
||||
return fromPixels(image, image.getWidth(), Math.min(image.getWidth(), image.getHeight()), fixedTint, biomeTint);
|
||||
}
|
||||
|
||||
// Entity textures, like a chest's, are laid out for a model and can be wider than they are tall.
|
||||
static BlockTexture fromWholeImage(BufferedImage image, int fixedTint) {
|
||||
return fromPixels(image, image.getWidth(), image.getHeight(), fixedTint, null);
|
||||
}
|
||||
|
||||
static BlockTexture solid(int color) {
|
||||
return new BlockTexture(1, 1, new int[]{color}, new boolean[]{true}, null, color, null);
|
||||
}
|
||||
|
||||
private static BlockTexture fromPixels(BufferedImage image, int width, int height, int fixedTint, BiomeTint biomeTint) {
|
||||
int[] pixels = new int[width * height];
|
||||
boolean[] opaque = new boolean[pixels.length];
|
||||
int[] alphas = new int[pixels.length];
|
||||
boolean translucent = false;
|
||||
long red = 0;
|
||||
long green = 0;
|
||||
long blue = 0;
|
||||
int opaqueCount = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int argb = image.getRGB(x, y);
|
||||
int index = y * width + x;
|
||||
int alpha = argb >>> 24;
|
||||
alphas[index] = alpha;
|
||||
if (alpha == 0) {
|
||||
continue;
|
||||
}
|
||||
translucent |= alpha < FULL_ALPHA;
|
||||
int color = multiply(argb & 0xFFFFFF, fixedTint);
|
||||
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(width, height, pixels, opaque, translucent ? alphas : null, average, biomeTint);
|
||||
}
|
||||
|
||||
boolean isTranslucent() {
|
||||
return alphas != null;
|
||||
}
|
||||
|
||||
boolean isFullyOpaque() {
|
||||
if (isTranslucent()) {
|
||||
return false;
|
||||
}
|
||||
for (boolean pixelOpaque : opaque) {
|
||||
if (!pixelOpaque) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
BlockTexture flattened() {
|
||||
int[] flat = new int[pixels.length];
|
||||
Arrays.fill(flat, averageColor);
|
||||
boolean[] allOpaque = new boolean[pixels.length];
|
||||
Arrays.fill(allOpaque, true);
|
||||
return new BlockTexture(width, height, flat, allOpaque, null, averageColor, biomeTint);
|
||||
}
|
||||
|
||||
BlockTexture tinted(int tint) {
|
||||
int[] tintedPixels = new int[pixels.length];
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
tintedPixels[index] = multiply(pixels[index], tint);
|
||||
}
|
||||
return new BlockTexture(width, height, tintedPixels, opaque, alphas, multiply(averageColor, tint), null);
|
||||
}
|
||||
|
||||
int texelAt(double worldX, double worldZ) {
|
||||
return pixels[texelIndex(worldX, worldZ)];
|
||||
}
|
||||
|
||||
boolean isOpaqueAt(double worldX, double worldZ) {
|
||||
return opaque[texelIndex(worldX, worldZ)];
|
||||
}
|
||||
|
||||
private int texelIndex(double worldX, double worldZ) {
|
||||
int x = Math.min(width - 1, (int) ((worldX - Math.floor(worldX)) * width));
|
||||
int z = Math.min(height - 1, (int) ((worldZ - Math.floor(worldZ)) * height));
|
||||
return z * width + x;
|
||||
}
|
||||
|
||||
BufferedImage toImage() {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
image.setRGB(0, 0, width, height, pixels, 0, width);
|
||||
return image;
|
||||
}
|
||||
|
||||
// The GPU multiplies the texture by the tint in floating point and rounds back to 8 bits per channel.
|
||||
static int multiply(int color, int tint) {
|
||||
return multiplyChannel(color, tint, 16) << 16 | multiplyChannel(color, tint, 8) << 8 | multiplyChannel(color, tint, 0);
|
||||
}
|
||||
|
||||
private static int multiplyChannel(int color, int tint, int shift) {
|
||||
return (((color >>> shift) & 0xFF) * ((tint >>> shift) & 0xFF) + 127) / 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 javax.imageio.ImageIO;
|
||||
import java.awt.color.ColorSpace;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.Raster;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class ClientJarFiles {
|
||||
private ClientJarFiles() {
|
||||
}
|
||||
|
||||
static JsonObject readJson(ZipFile clientJar, 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();
|
||||
}
|
||||
}
|
||||
|
||||
static BufferedImage readImage(ZipFile clientJar, String path) throws IOException {
|
||||
ZipEntry entry = clientJar.getEntry(path);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream input = clientJar.getInputStream(entry)) {
|
||||
BufferedImage image = ImageIO.read(input);
|
||||
return image != null && isGrey(image) ? withGreyKept(image) : image;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isGrey(BufferedImage image) {
|
||||
return image.getColorModel().getColorSpace().getType() == ColorSpace.TYPE_GRAY;
|
||||
}
|
||||
|
||||
// Java treats grey PNGs, like stone's, as linear light and brightens them when converting to RGB; Minecraft reads
|
||||
// the stored values as they are.
|
||||
private static BufferedImage withGreyKept(BufferedImage image) {
|
||||
Raster raster = image.getRaster();
|
||||
boolean hasAlpha = raster.getNumBands() > 1;
|
||||
int greyMax = (1 << raster.getSampleModel().getSampleSize(0)) - 1;
|
||||
int alphaMax = hasAlpha ? (1 << raster.getSampleModel().getSampleSize(1)) - 1 : 1;
|
||||
BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
int grey = raster.getSample(x, y, 0) * 255 / greyMax;
|
||||
int alpha = hasAlpha ? raster.getSample(x, y, 1) * 255 / alphaMax : 255;
|
||||
converted.setRGB(x, y, alpha << 24 | grey << 16 | grey << 8 | grey);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The six sides of a block, named as in Minecraft's model files, with the light Minecraft gives each side.
|
||||
*/
|
||||
enum CubeFace {
|
||||
UP("up", 0, 1, 0, 1.0D),
|
||||
DOWN("down", 0, -1, 0, 0.5D),
|
||||
NORTH("north", 0, 0, -1, 0.8D),
|
||||
SOUTH("south", 0, 0, 1, 0.8D),
|
||||
EAST("east", 1, 0, 0, 0.6D),
|
||||
WEST("west", -1, 0, 0, 0.6D);
|
||||
|
||||
private final String modelName;
|
||||
private final int stepX;
|
||||
private final int stepY;
|
||||
private final int stepZ;
|
||||
private final double shade;
|
||||
|
||||
CubeFace(String modelName, int stepX, int stepY, int stepZ, double shade) {
|
||||
this.modelName = modelName;
|
||||
this.stepX = stepX;
|
||||
this.stepY = stepY;
|
||||
this.stepZ = stepZ;
|
||||
this.shade = shade;
|
||||
}
|
||||
|
||||
static CubeFace named(String modelName) {
|
||||
for (CubeFace face : values()) {
|
||||
if (face.modelName.equals(modelName)) {
|
||||
return face;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The side a direction points at most, like Minecraft picks for a tilted face.
|
||||
*/
|
||||
static CubeFace nearest(Vec3 direction) {
|
||||
CubeFace nearest = UP;
|
||||
double best = Double.NEGATIVE_INFINITY;
|
||||
for (CubeFace face : values()) {
|
||||
double alignment = face.stepX * direction.x() + face.stepY * direction.y() + face.stepZ * direction.z();
|
||||
if (alignment > best) {
|
||||
best = alignment;
|
||||
nearest = face;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
String modelName() {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
int stepX() {
|
||||
return stepX;
|
||||
}
|
||||
|
||||
int stepY() {
|
||||
return stepY;
|
||||
}
|
||||
|
||||
int stepZ() {
|
||||
return stepZ;
|
||||
}
|
||||
|
||||
double shade() {
|
||||
return shade;
|
||||
}
|
||||
|
||||
Vec3 direction() {
|
||||
return new Vec3(stepX, stepY, stepZ);
|
||||
}
|
||||
|
||||
CubeFace opposite() {
|
||||
return switch (this) {
|
||||
case UP -> DOWN;
|
||||
case DOWN -> UP;
|
||||
case NORTH -> SOUTH;
|
||||
case SOUTH -> NORTH;
|
||||
case EAST -> WEST;
|
||||
case WEST -> EAST;
|
||||
};
|
||||
}
|
||||
|
||||
int axis() {
|
||||
return stepX != 0 ? 0 : stepY != 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
boolean pointsPositive() {
|
||||
return stepX + stepY + stepZ > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 javax.swing.Icon;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
|
||||
/**
|
||||
* What an empty view says, drawn in the middle of it the way IntelliJ fills an empty tool window: a large icon, what
|
||||
* is missing, and where to add it.
|
||||
*
|
||||
* @param location the pack folder or file the hint points to, drawn as code
|
||||
*/
|
||||
record EmptyState(Icon icon, String title, String hint, String location, String footer) {
|
||||
private static final int ICON_GAP = 18;
|
||||
private static final int TITLE_GAP = 8;
|
||||
private static final int LOCATION_GAP = 10;
|
||||
private static final int FOOTER_GAP = 14;
|
||||
private static final int CHIP_PADDING_X = 8;
|
||||
private static final int CHIP_PADDING_Y = 4;
|
||||
private static final int CHIP_ARC = 8;
|
||||
|
||||
/**
|
||||
* Draws the empty state centred on the given point.
|
||||
*/
|
||||
void paint(Graphics2D graphics, Component owner, int centerX, int centerY) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
int top = centerY - height(canvas) / 2;
|
||||
icon.paintIcon(owner, canvas, centerX - icon.getIconWidth() / 2, top);
|
||||
top += icon.getIconHeight() + StudioTheme.scale(ICON_GAP);
|
||||
top = drawCentered(canvas, title, StudioTheme.titleFont(), StudioTheme.TEXT, centerX, top);
|
||||
top = drawCentered(canvas, hint, StudioTheme.font(), StudioTheme.SECONDARY_TEXT, centerX,
|
||||
top + StudioTheme.scale(TITLE_GAP));
|
||||
top = drawLocationChip(canvas, centerX, top + StudioTheme.scale(LOCATION_GAP));
|
||||
drawCentered(canvas, footer, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, centerX, top + StudioTheme.scale(FOOTER_GAP));
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private int height(Graphics2D canvas) {
|
||||
return icon.getIconHeight() + StudioTheme.scale(ICON_GAP)
|
||||
+ canvas.getFontMetrics(StudioTheme.titleFont()).getHeight()
|
||||
+ StudioTheme.scale(TITLE_GAP) + canvas.getFontMetrics(StudioTheme.font()).getHeight()
|
||||
+ StudioTheme.scale(LOCATION_GAP) + chipHeight(canvas)
|
||||
+ StudioTheme.scale(FOOTER_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
|
||||
}
|
||||
|
||||
private int chipHeight(Graphics2D canvas) {
|
||||
return canvas.getFontMetrics(StudioTheme.monospacedFont()).getHeight() + StudioTheme.scale(CHIP_PADDING_Y) * 2;
|
||||
}
|
||||
|
||||
private int drawLocationChip(Graphics2D canvas, int centerX, int top) {
|
||||
canvas.setFont(StudioTheme.monospacedFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int width = metrics.stringWidth(location) + StudioTheme.scale(CHIP_PADDING_X) * 2;
|
||||
int height = chipHeight(canvas);
|
||||
int left = centerX - width / 2;
|
||||
RoundRectangle2D chip = new RoundRectangle2D.Double(left + 0.5D, top + 0.5D, width - 1D, height - 1D,
|
||||
StudioTheme.scale(CHIP_ARC), StudioTheme.scale(CHIP_ARC));
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fill(chip);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.draw(chip);
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
canvas.drawString(location, left + StudioTheme.scale(CHIP_PADDING_X), top + StudioTheme.scale(CHIP_PADDING_Y) + metrics.getAscent());
|
||||
return top + height;
|
||||
}
|
||||
|
||||
private static int drawCentered(Graphics2D canvas, String text, Font font, Color color, int centerX, int top) {
|
||||
canvas.setFont(font);
|
||||
canvas.setColor(color);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
canvas.drawString(text, centerX - metrics.stringWidth(text) / 2, top + metrics.getAscent());
|
||||
return top + metrics.getHeight();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* 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.BlockModel.Face;
|
||||
import com.volmit.iris.core.gui.BlockModel.TextureMapping;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockStateId;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.core.gui.BlockTextures.ModelDefinition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Shapes for the blocks Minecraft draws in code instead of from a model file, like chests, heads, banners and
|
||||
* fluids, which fill their block. Chests, heads, banners and the bell use their entity textures laid out the way Minecraft's model parts are;
|
||||
* rarer ones are a simple box in their particle texture.
|
||||
*/
|
||||
final class EntityBlockShapes {
|
||||
private static final Set<String> INVISIBLE = Set.of("air", "cave_air", "void_air", "barrier", "light",
|
||||
"structure_void", "moving_piston", "pitcher_crop");
|
||||
private static final int END_PORTAL_COLOR = 0x0E1219;
|
||||
private static final double SKULL_WALL_OFFSET = 0.25D;
|
||||
private static final double ROTATION_SEGMENT_DEGREES = 22.5D;
|
||||
private static final double BANNER_SCALE = 2D / 3D;
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final Map<String, Integer> DYE_COLORS = Map.ofEntries(
|
||||
Map.entry("white", 0xF9FFFE), Map.entry("orange", 0xF9801D), Map.entry("magenta", 0xC74EBD),
|
||||
Map.entry("light_blue", 0x3AB3DA), Map.entry("yellow", 0xFED83D), Map.entry("lime", 0x80C71F),
|
||||
Map.entry("pink", 0xF38BAA), Map.entry("gray", 0x474F52), Map.entry("light_gray", 0x9D9D97),
|
||||
Map.entry("cyan", 0x169C9C), Map.entry("purple", 0x8932B8), Map.entry("blue", 0x3C44AA),
|
||||
Map.entry("brown", 0x835432), Map.entry("green", 0x5E7C16), Map.entry("red", 0xB02E26),
|
||||
Map.entry("black", 0x1D1D21));
|
||||
|
||||
private final BlockTextures textures;
|
||||
|
||||
EntityBlockShapes(BlockTextures textures) {
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* The faces of a block whose model has no boxes of its own.
|
||||
*/
|
||||
List<Face> facesOf(BlockStateId state, ModelDefinition model) {
|
||||
String name = state.name();
|
||||
if (INVISIBLE.contains(name)) {
|
||||
return List.of();
|
||||
}
|
||||
if (name.endsWith("chest")) {
|
||||
return chest(state);
|
||||
}
|
||||
if (name.endsWith("_skull") || name.endsWith("_head")) {
|
||||
return head(state, model);
|
||||
}
|
||||
if (name.endsWith("banner")) {
|
||||
return banner(state);
|
||||
}
|
||||
return switch (name) {
|
||||
case "conduit" -> particleBox(state, model, new Vec3(5, 5, 5), new Vec3(11, 11, 11), false);
|
||||
case "decorated_pot" -> decoratedPot(state, model);
|
||||
case "end_portal" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 12, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
|
||||
case "end_gateway" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 16, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
|
||||
default -> name.endsWith("copper_golem_statue")
|
||||
? particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 14, 12), false)
|
||||
: particleBox(state, model, new Vec3(0, 0, 0), new Vec3(16, 16, 16), true);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts a block draws in code on top of its model, like the bell hanging in its frame.
|
||||
*/
|
||||
List<Face> facesAddedTo(BlockStateId state) {
|
||||
if (!state.name().equals("bell")) {
|
||||
return List.of();
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(5, 6, 5), new Vec3(6, 7, 6), Transform.IDENTITY));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 13, new Vec3(4, 4, 4), new Vec3(8, 2, 8), Transform.IDENTITY));
|
||||
return faces;
|
||||
}
|
||||
|
||||
private List<Face> particleBox(BlockStateId state, ModelDefinition model, Vec3 from, Vec3 to, boolean culledBySides) {
|
||||
String particle = BlockTextures.dereference("#particle", model.textures());
|
||||
if (particle == null) {
|
||||
return List.of();
|
||||
}
|
||||
return textures.faceTexture(state, particle, BlockTextures.NO_TINT_INDEX)
|
||||
.map(texture -> BlockModelBuilder.box(from, to, texture, culledBySides))
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
private List<Face> decoratedPot(BlockStateId state, ModelDefinition model) {
|
||||
List<Face> faces = new ArrayList<>(particleBox(state, model, new Vec3(1, 0, 1), new Vec3(15, 16, 15), false));
|
||||
faces.addAll(particleBox(state, model, new Vec3(4, 16, 4), new Vec3(12, 20, 12), false));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Single chests are 14 pixels wide; each half of a double chest is 15, reaching over to its partner.
|
||||
private List<Face> chest(BlockStateId state) {
|
||||
String type = state.properties().getOrDefault("type", "single");
|
||||
String suffix = type.equals("single") ? "" : "_" + type;
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/chest/" + chestTextureName(state.name()) + suffix, MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Transform place = Transform.IDENTITY
|
||||
.translate(0.5D, 0.5D, 0.5D)
|
||||
.rotateY(-yRotationOf(state.properties().getOrDefault("facing", "north")))
|
||||
.translate(-0.5D, -0.5D, -0.5D);
|
||||
double left = type.equals("left") ? 0D : 1D;
|
||||
double width = type.equals("single") ? 14D : 15D;
|
||||
double lockX = switch (type) {
|
||||
case "left" -> 0D;
|
||||
case "right" -> 15D;
|
||||
default -> 7D;
|
||||
};
|
||||
double lockWidth = type.equals("single") ? 2D : 1D;
|
||||
List<Face> faces = new ArrayList<>();
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 19, new Vec3(left, 0, 1), new Vec3(width, 10, 14), place));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(left, 9, 1), new Vec3(width, 5, 14), place));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(lockX, 7, 15), new Vec3(lockWidth, 4, 1), place));
|
||||
return faces;
|
||||
}
|
||||
|
||||
private static String chestTextureName(String blockName) {
|
||||
String name = blockName.startsWith("waxed_") ? blockName.substring("waxed_".length()) : blockName;
|
||||
return switch (name) {
|
||||
case "trapped_chest" -> "trapped";
|
||||
case "ender_chest" -> "ender";
|
||||
case "copper_chest" -> "copper";
|
||||
case "exposed_copper_chest" -> "copper_exposed";
|
||||
case "weathered_copper_chest" -> "copper_weathered";
|
||||
case "oxidized_copper_chest" -> "copper_oxidized";
|
||||
default -> "normal";
|
||||
};
|
||||
}
|
||||
|
||||
// Heads are drawn upside down and mirrored in their model, and turn in sixteenths like signs.
|
||||
private List<Face> head(BlockStateId state, ModelDefinition model) {
|
||||
boolean onWall = state.name().contains("_wall_");
|
||||
String kind = state.name().replace("_wall_", "_");
|
||||
Optional<HeadLook> look = HeadLook.of(kind);
|
||||
Optional<BlockTexture> texture = look.flatMap(found -> textures.entityTexture(found.texture(), MinecraftBlockTints.NO_TINT));
|
||||
if (texture.isEmpty()) {
|
||||
return onWall
|
||||
? particleBox(state, model, new Vec3(4, 4, 8), new Vec3(12, 12, 16), false)
|
||||
: particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 8, 12), false);
|
||||
}
|
||||
Transform place;
|
||||
if (onWall) {
|
||||
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D - facing.stepX() * SKULL_WALL_OFFSET, SKULL_WALL_OFFSET, 0.5D - facing.stepZ() * SKULL_WALL_OFFSET)
|
||||
.scale(-1D, -1D, 1D)
|
||||
.rotateY(yRotationOf(facing.opposite().modelName()));
|
||||
} else {
|
||||
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, 0D, 0.5D)
|
||||
.scale(-1D, -1D, 1D)
|
||||
.rotateY(rotation * ROTATION_SEGMENT_DEGREES);
|
||||
}
|
||||
HeadLook found = look.get();
|
||||
return modelPartBox(texture.get(), 0, 0, new Vec3(-found.width() / 2D, -8, -4), new Vec3(found.width(), 8, 8), place);
|
||||
}
|
||||
|
||||
private List<Face> banner(BlockStateId state) {
|
||||
boolean onWall = state.name().endsWith("_wall_banner");
|
||||
String color = state.name().substring(0, state.name().length() - (onWall ? "_wall_banner" : "_banner").length());
|
||||
Optional<BlockTexture> frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT);
|
||||
Optional<BlockTexture> cloth = textures.entityTexture("entity/banner/base", DYE_COLORS.getOrDefault(color, MinecraftBlockTints.NO_TINT));
|
||||
if (frame.isEmpty() || cloth.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Transform place;
|
||||
if (onWall) {
|
||||
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, -1D / 6D, 0.5D)
|
||||
.rotateY(-yRotationOf(facing.modelName()))
|
||||
.translate(0D, -0.3125D, -0.4375D)
|
||||
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
|
||||
} else {
|
||||
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, 0.5D, 0.5D)
|
||||
.rotateY(-rotation * ROTATION_SEGMENT_DEGREES)
|
||||
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
if (!onWall) {
|
||||
faces.addAll(modelPartBox(frame.get(), 44, 0, new Vec3(-1, -30, -1), new Vec3(2, 42, 2), place));
|
||||
}
|
||||
faces.addAll(modelPartBox(frame.get(), 0, 42, new Vec3(-10, -32, -1), new Vec3(20, 2, 2), place));
|
||||
faces.addAll(modelPartBox(cloth.get(), 0, 0, new Vec3(-10, -32, -2), new Vec3(20, 40, 1), place));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Minecraft's facing angle: south is 0 and each quarter turn clockwise seen from above adds 90 degrees.
|
||||
private static double yRotationOf(String facing) {
|
||||
return switch (facing) {
|
||||
case "west" -> 90D;
|
||||
case "north" -> 180D;
|
||||
case "east" -> 270D;
|
||||
default -> 0D;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A box of a block entity's model, in model pixels, with its sides laid out on the texture the way Minecraft's
|
||||
* model parts are: the top and bottom in a row above the four sides.
|
||||
*/
|
||||
static List<Face> modelPartBox(BlockTexture texture, int textureU, int textureV, Vec3 from, Vec3 size, Transform place) {
|
||||
double minX = from.x() / MODEL_UNITS;
|
||||
double minY = from.y() / MODEL_UNITS;
|
||||
double minZ = from.z() / MODEL_UNITS;
|
||||
double maxX = (from.x() + size.x()) / MODEL_UNITS;
|
||||
double maxY = (from.y() + size.y()) / MODEL_UNITS;
|
||||
double maxZ = (from.z() + size.z()) / MODEL_UNITS;
|
||||
Vec3 v0 = new Vec3(minX, minY, minZ);
|
||||
Vec3 v1 = new Vec3(maxX, minY, minZ);
|
||||
Vec3 v2 = new Vec3(maxX, maxY, minZ);
|
||||
Vec3 v3 = new Vec3(minX, maxY, minZ);
|
||||
Vec3 v4 = new Vec3(minX, minY, maxZ);
|
||||
Vec3 v5 = new Vec3(maxX, minY, maxZ);
|
||||
Vec3 v6 = new Vec3(maxX, maxY, maxZ);
|
||||
Vec3 v7 = new Vec3(minX, maxY, maxZ);
|
||||
double u = textureU;
|
||||
double v = textureV;
|
||||
double width = size.x();
|
||||
double height = size.y();
|
||||
double depth = size.z();
|
||||
List<Face> faces = new ArrayList<>(6);
|
||||
faces.add(polygon(texture, place, CubeFace.DOWN, v5, v4, v0, u + depth, v, u + depth + width, v + depth));
|
||||
faces.add(polygon(texture, place, CubeFace.UP, v2, v3, v7, u + depth + width, v + depth, u + depth + width + width, v));
|
||||
faces.add(polygon(texture, place, CubeFace.WEST, v0, v4, v7, u, v + depth, u + depth, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.NORTH, v1, v0, v3, u + depth, v + depth, u + depth + width, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.EAST, v5, v1, v2, u + depth + width, v + depth, u + depth + width + depth, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.SOUTH, v4, v5, v6, u + depth + width + depth, v + depth, u + depth + width + depth + width, v + depth + height));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A model part's polygon gives its first corner (u2, v1), its second (u1, v1) and its third (u1, v2).
|
||||
private static Face polygon(BlockTexture texture, Transform place, CubeFace side, Vec3 first, Vec3 second, Vec3 third,
|
||||
double u1, double v1, double u2, double v2) {
|
||||
double textureWidth = texture.width();
|
||||
double textureHeight = texture.height();
|
||||
TextureMapping mapping = new TextureMapping(u1 / textureWidth, v1 / textureHeight,
|
||||
(u2 - u1) / textureWidth, 0D, 0D, (v2 - v1) / textureHeight);
|
||||
Face face = new Face(second, first.minus(second), third.minus(second), side.direction(), texture, mapping, side.shade(), null)
|
||||
.transformed(place);
|
||||
return face.withShade(CubeFace.nearest(face.normal()).shade());
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity texture of a mob head and how wide its head is.
|
||||
*/
|
||||
private record HeadLook(String texture, double width) {
|
||||
private static final double HEAD_WIDTH = 8D;
|
||||
private static final double PIGLIN_HEAD_WIDTH = 10D;
|
||||
|
||||
static Optional<HeadLook> of(String kind) {
|
||||
return Optional.ofNullable(switch (kind) {
|
||||
case "skeleton_skull" -> new HeadLook("entity/skeleton/skeleton", HEAD_WIDTH);
|
||||
case "wither_skeleton_skull" -> new HeadLook("entity/skeleton/wither_skeleton", HEAD_WIDTH);
|
||||
case "zombie_head" -> new HeadLook("entity/zombie/zombie", HEAD_WIDTH);
|
||||
case "creeper_head" -> new HeadLook("entity/creeper/creeper", HEAD_WIDTH);
|
||||
case "player_head" -> new HeadLook("entity/player/wide/steve", HEAD_WIDTH);
|
||||
case "piglin_head" -> new HeadLook("entity/piglin/piglin", PIGLIN_HEAD_WIDTH);
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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.events.IrisEngineHotloadEvent;
|
||||
import com.volmit.iris.core.project.IrisProject;
|
||||
import com.volmit.iris.core.service.PreservationSVC;
|
||||
import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import javax.swing.JFrame;
|
||||
import java.awt.Desktop;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.desktop.QuitResponse;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class GuiHost {
|
||||
private static final AtomicBoolean DESKTOP_QUIT_GUARD_INSTALLED = new AtomicBoolean(false);
|
||||
private static final Set<JFrame> MANAGED_FRAMES = ConcurrentHashMap.newKeySet();
|
||||
private static final Map<Runnable, Listener> HOTLOAD_HOOKS = new ConcurrentHashMap<>();
|
||||
|
||||
private GuiHost() {
|
||||
}
|
||||
|
||||
public static boolean isAvailable() {
|
||||
return !GraphicsEnvironment.isHeadless();
|
||||
}
|
||||
|
||||
public static JFrame createFrame(String title) {
|
||||
XwaylandWindowManagerHint.apply();
|
||||
StudioTheme.install();
|
||||
JFrame frame = new JFrame(title);
|
||||
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
|
||||
MANAGED_FRAMES.add(frame);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
MANAGED_FRAMES.remove(frame);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowActivated(WindowEvent event) {
|
||||
StudioTheme.followSystem();
|
||||
}
|
||||
});
|
||||
installMacQuitGuard();
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps rendered pixels in an image Java2D draws straight from memory. Left to itself Java2D copies every image it
|
||||
* draws into a texture inside the display server; with a map's hundreds of tiles that crashed Xwayland's Intel driver
|
||||
* and took every X window on the desktop down with it.
|
||||
*/
|
||||
public static BufferedImage uncachedImage(int[] pixels, int width, int height) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
image.getRaster().setDataElements(0, 0, width, height, pixels);
|
||||
image.setAccelerationPriority(0F);
|
||||
return image;
|
||||
}
|
||||
|
||||
// A scaled-up default size can be bigger than a small or low resolution screen.
|
||||
static Dimension fitToScreen(Dimension preferred) {
|
||||
Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||
return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height));
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine of the open studio project, which reads the pack folder you edit, or else the first Iris world.
|
||||
* A normal Iris world runs on its own copy of the pack, made when the world was created.
|
||||
*/
|
||||
public static Engine findActiveEngine() {
|
||||
Engine studioEngine = findStudioEngine();
|
||||
if (studioEngine != null) {
|
||||
return studioEngine;
|
||||
}
|
||||
try {
|
||||
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
|
||||
Engine engine = engineOf(world);
|
||||
if (engine != null) {
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Engine findStudioEngine() {
|
||||
try {
|
||||
StudioSVC studio = Iris.service(StudioSVC.class);
|
||||
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
|
||||
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
|
||||
Engine engine = provider == null ? null : provider.getEngine();
|
||||
return engine == null || engine.isClosed() ? null : engine;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Engine findEngine(UUID worldId) {
|
||||
World world = Bukkit.getWorld(worldId);
|
||||
return world == null ? null : engineOf(world);
|
||||
}
|
||||
|
||||
public static void registerHotloadHook(Runnable onHotload) {
|
||||
Listener listener = new HotloadListener(onHotload);
|
||||
HOTLOAD_HOOKS.put(onHotload, listener);
|
||||
Iris.instance.registerListener(listener);
|
||||
}
|
||||
|
||||
public static void unregisterHotloadHook(Runnable onHotload) {
|
||||
Listener listener = HOTLOAD_HOOKS.remove(onHotload);
|
||||
if (listener != null) {
|
||||
Iris.instance.unregisterListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
static void interruptOnPluginShutdown(ExecutorService executor) {
|
||||
PreservationSVC preservation = Iris.service(PreservationSVC.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(executor);
|
||||
}
|
||||
}
|
||||
|
||||
static void interruptOnPluginShutdown(Thread thread) {
|
||||
PreservationSVC preservation = Iris.service(PreservationSVC.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(thread);
|
||||
}
|
||||
}
|
||||
|
||||
private static Engine engineOf(World world) {
|
||||
try {
|
||||
PlatformChunkGenerator access = IrisToolbelt.access(world);
|
||||
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
|
||||
return access.getEngine();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS Quit in the dock menu would otherwise stop the whole server.
|
||||
private static void installMacQuitGuard() {
|
||||
if (!isAvailable()
|
||||
|| !System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac")
|
||||
|| !DESKTOP_QUIT_GUARD_INSTALLED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
return;
|
||||
}
|
||||
Desktop desktop = Desktop.getDesktop();
|
||||
if (!desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
|
||||
return;
|
||||
}
|
||||
desktop.setQuitHandler((event, response) -> closeWindowsAndCancelQuit(response));
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
Iris.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeWindowsAndCancelQuit(QuitResponse response) {
|
||||
response.cancelQuit();
|
||||
EventQueue.invokeLater(() -> {
|
||||
for (JFrame frame : MANAGED_FRAMES) {
|
||||
frame.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class HotloadListener implements Listener {
|
||||
private final Runnable onHotload;
|
||||
|
||||
private HotloadListener(Runnable onHotload) {
|
||||
this.onHotload = onHotload;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void on(IrisEngineHotloadEvent event) {
|
||||
onHotload.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
public record GuiMarker(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) {
|
||||
public static GuiMarker player(String name, double worldX, double worldZ) {
|
||||
return new GuiMarker(name, worldX, 0, worldZ, 0, 0);
|
||||
}
|
||||
|
||||
public static GuiMarker entity(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) {
|
||||
return new GuiMarker(label, worldX, worldY, worldZ, health, maxHealth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 javax.swing.JComponent;
|
||||
import javax.swing.JLayeredPane;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
|
||||
/**
|
||||
* Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it,
|
||||
* always leaving the content some room beside it.
|
||||
*/
|
||||
final class IslandOverlay extends JLayeredPane {
|
||||
private static final long serialVersionUID = -4182209135360718342L;
|
||||
|
||||
private final IslandPanel island;
|
||||
private final JComponent background;
|
||||
private final int minimumIslandWidth;
|
||||
private final int minimumUncoveredWidth;
|
||||
private int islandWidth;
|
||||
|
||||
IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) {
|
||||
this.island = island;
|
||||
this.background = background;
|
||||
this.islandWidth = islandWidth;
|
||||
this.minimumIslandWidth = minimumIslandWidth;
|
||||
this.minimumUncoveredWidth = minimumUncoveredWidth;
|
||||
add(background, JLayeredPane.DEFAULT_LAYER);
|
||||
add(island, JLayeredPane.PALETTE_LAYER);
|
||||
installResizing();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
background.setBounds(0, 0, getWidth(), getHeight());
|
||||
island.setBounds(0, 0, clampedIslandWidth(), getHeight());
|
||||
}
|
||||
|
||||
private int clampedIslandWidth() {
|
||||
int widest = Math.max(minimumIslandWidth, getWidth() - minimumUncoveredWidth);
|
||||
return Math.clamp(islandWidth, minimumIslandWidth, widest);
|
||||
}
|
||||
|
||||
private void installResizing() {
|
||||
MouseAdapter resizer = new MouseAdapter() {
|
||||
private boolean dragging;
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
island.setCursor(island.isOnResizeEdge(event.getX(), event.getY())
|
||||
? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
|
||||
: Cursor.getDefaultCursor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
if (!dragging) {
|
||||
island.setCursor(Cursor.getDefaultCursor());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent event) {
|
||||
dragging = island.isOnResizeEdge(event.getX(), event.getY());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent event) {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
islandWidth = event.getX();
|
||||
islandWidth = clampedIslandWidth();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
dragging = false;
|
||||
}
|
||||
};
|
||||
island.addMouseListener(resizer);
|
||||
island.addMouseMotionListener(resizer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 javax.swing.JComponent;
|
||||
import javax.swing.JPanel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
|
||||
/**
|
||||
* A rounded panel floating over the window content with a gap all around it, like the tool windows of IntelliJ's
|
||||
* islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the
|
||||
* island to resize it.
|
||||
*/
|
||||
final class IslandPanel extends JPanel {
|
||||
private static final long serialVersionUID = 7313084027614358519L;
|
||||
private static final int GAP = 6;
|
||||
private static final int CORNER_ARC = 12;
|
||||
private static final int INNER_PADDING = 4;
|
||||
|
||||
IslandPanel(JComponent content) {
|
||||
super(new BorderLayout());
|
||||
setOpaque(false);
|
||||
int inset = GAP + INNER_PADDING;
|
||||
setBorder(StudioTheme.emptyBorder(inset, inset, inset, inset));
|
||||
add(content, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
boolean isOnResizeEdge(int x, int y) {
|
||||
int edgeWidth = StudioTheme.scale(GAP + INNER_PADDING);
|
||||
return x >= getWidth() - edgeWidth && x < getWidth() && y >= 0 && y < getHeight();
|
||||
}
|
||||
|
||||
// Clicks in the gap reach the content behind the island instead of the island itself.
|
||||
@Override
|
||||
public boolean contains(int x, int y) {
|
||||
return island().contains(x, y) || isOnResizeEdge(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fill(island());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private RoundRectangle2D island() {
|
||||
double gap = StudioTheme.scale(GAP);
|
||||
double arc = StudioTheme.scale(CORNER_ARC);
|
||||
return new RoundRectangle2D.Double(gap, gap, getWidth() - gap * 2D, getHeight() - gap * 2D, arc, arc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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 java.awt.EventQueue;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Measures off the Swing thread, one measurement at a time, and only hands back the newest one asked for.
|
||||
*/
|
||||
final class LatestMeasurement<T> {
|
||||
private final ThreadPoolExecutor worker;
|
||||
private final AtomicLong generation = new AtomicLong();
|
||||
|
||||
LatestMeasurement(String threadName) {
|
||||
this.worker = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1), task -> {
|
||||
Thread thread = new Thread(task, threadName);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}, new ThreadPoolExecutor.DiscardOldestPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the measurement and shows its result on the Swing thread, unless another was asked for meanwhile or it
|
||||
* measured nothing.
|
||||
*/
|
||||
void measure(Supplier<T> measurement, Consumer<T> show) {
|
||||
long requested = generation.incrementAndGet();
|
||||
worker.execute(() -> {
|
||||
T result = measureSafely(measurement);
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (requested == generation.get() && result != null) {
|
||||
show.accept(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the measurement under way, whose result no longer fits.
|
||||
*/
|
||||
void discardPending() {
|
||||
generation.incrementAndGet();
|
||||
}
|
||||
|
||||
void close() {
|
||||
worker.shutdownNow();
|
||||
}
|
||||
|
||||
private static <T> T measureSafely(Supplier<T> measurement) {
|
||||
try {
|
||||
return measurement.get();
|
||||
} catch (Throwable error) {
|
||||
Iris.reportError(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 java.awt.AlphaComposite;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
/**
|
||||
* A little cube of blocks building itself while something loads: the blocks drop in one by one, the cube holds a
|
||||
* moment, fades, and starts over. Loads shorter than {@link #SHOW_AFTER_MILLIS} never show it, so they don't flash.
|
||||
*/
|
||||
final class LoadingAnimation {
|
||||
static final int SHOW_AFTER_MILLIS = 300;
|
||||
static final int FRAME_MILLIS = 33;
|
||||
|
||||
// Back to front, so each block lands on or beside ones already there.
|
||||
private static final int[][] BUILD_ORDER = {
|
||||
{0, 0, 0}, {1, 0, 0}, {0, 0, 1}, {1, 0, 1},
|
||||
{0, 1, 0}, {1, 1, 0}, {0, 1, 1}, {1, 1, 1}};
|
||||
private static final int STEP_MILLIS = 140;
|
||||
private static final int DROP_MILLIS = 260;
|
||||
private static final int HOLD_MILLIS = 450;
|
||||
private static final int FADE_MILLIS = 320;
|
||||
private static final int CYCLE_MILLIS = STEP_MILLIS * (BUILD_ORDER.length - 1) + DROP_MILLIS + HOLD_MILLIS + FADE_MILLIS;
|
||||
private static final double DROP_HEIGHT_BLOCKS = 1.6D;
|
||||
private static final double ISOMETRIC_WIDTH = Math.cos(Math.PI / 6D);
|
||||
private static final double ISOMETRIC_DEPTH = 0.5D;
|
||||
private static final float SIDE_SHADE = 0.78F;
|
||||
private static final float FAR_SIDE_SHADE = 0.6F;
|
||||
|
||||
private LoadingAnimation() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a load that began at {@code loadingSinceMillis} has taken long enough to show the animation.
|
||||
*/
|
||||
static boolean isDue(long loadingSinceMillis) {
|
||||
return System.currentTimeMillis() - loadingSinceMillis >= SHOW_AFTER_MILLIS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the cube centred on a point, {@code blockSize} pixels to a block edge, as it looks
|
||||
* {@code elapsedMillis} after it first showed.
|
||||
*/
|
||||
static void paint(Graphics2D graphics, double centerX, double centerY, double blockSize, long elapsedMillis) {
|
||||
long time = Math.floorMod(elapsedMillis, CYCLE_MILLIS);
|
||||
long fadeStart = CYCLE_MILLIS - FADE_MILLIS;
|
||||
if (time < fadeStart) {
|
||||
drawCube(graphics, centerX, centerY, blockSize, time);
|
||||
return;
|
||||
}
|
||||
fadeCube(graphics, centerX, centerY, blockSize, time, 1F - (time - fadeStart) / (float) FADE_MILLIS);
|
||||
}
|
||||
|
||||
// Faded block by block, the blocks behind would show through; the whole cube is drawn first and faded as one.
|
||||
private static void fadeCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time, float opacity) {
|
||||
int extent = (int) Math.ceil(blockSize * (2D + DROP_HEIGHT_BLOCKS) * 2D);
|
||||
BufferedImage cube = new BufferedImage(extent, extent, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D cubeCanvas = cube.createGraphics();
|
||||
drawCube(cubeCanvas, extent / 2D, extent / 2D, blockSize, time);
|
||||
cubeCanvas.dispose();
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0F, opacity)));
|
||||
canvas.drawImage(cube, (int) Math.round(centerX - extent / 2D), (int) Math.round(centerY - extent / 2D), null);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
for (int index = 0; index < BUILD_ORDER.length; index++) {
|
||||
long sinceDropStart = time - (long) index * STEP_MILLIS;
|
||||
if (sinceDropStart < 0) {
|
||||
break;
|
||||
}
|
||||
double fallen = easeOut(Math.min(1D, sinceDropStart / (double) DROP_MILLIS));
|
||||
int[] block = BUILD_ORDER[index];
|
||||
drawBlock(canvas, centerX, centerY, blockSize, block[0], block[1] + DROP_HEIGHT_BLOCKS * (1D - fallen), block[2]);
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static double easeOut(double progress) {
|
||||
double remaining = 1D - progress;
|
||||
return 1D - remaining * remaining * remaining;
|
||||
}
|
||||
|
||||
// The cube's middle, (1, 1, 1), lands on the centre point; the top and the two sides facing the viewer show.
|
||||
private static void drawBlock(Graphics2D canvas, double centerX, double centerY, double size, double x, double y, double z) {
|
||||
Color top = StudioTheme.ACCENT;
|
||||
canvas.setColor(top);
|
||||
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}));
|
||||
canvas.setColor(shaded(top, SIDE_SHADE));
|
||||
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{1, 0, 0}, {1, 1, 0}, {1, 1, 1}, {1, 0, 1}}));
|
||||
canvas.setColor(shaded(top, FAR_SIDE_SHADE));
|
||||
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}}));
|
||||
}
|
||||
|
||||
private static Path2D face(double centerX, double centerY, double size, double x, double y, double z, double[][] corners) {
|
||||
Path2D path = new Path2D.Double();
|
||||
for (int corner = 0; corner < corners.length; corner++) {
|
||||
double cornerX = x + corners[corner][0] - 1D;
|
||||
double cornerY = y + corners[corner][1] - 1D;
|
||||
double cornerZ = z + corners[corner][2] - 1D;
|
||||
double screenX = centerX + (cornerX - cornerZ) * ISOMETRIC_WIDTH * size;
|
||||
double screenY = centerY + (cornerX + cornerZ) * ISOMETRIC_DEPTH * size - cornerY * size;
|
||||
if (corner == 0) {
|
||||
path.moveTo(screenX, screenY);
|
||||
} else {
|
||||
path.lineTo(screenX, screenY);
|
||||
}
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Color shaded(Color color, float shade) {
|
||||
return new Color(Math.round(color.getRed() * shade), Math.round(color.getGreen() * shade), Math.round(color.getBlue() * shade));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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.components.MapLabels;
|
||||
import com.volmit.iris.core.gui.components.MapStyle;
|
||||
import com.volmit.iris.core.gui.components.MapTile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
/**
|
||||
* Measures the visible part of the map from the tiles already drawn: how much each biome or region covers, and which
|
||||
* heights it spans.
|
||||
*/
|
||||
final class MapCensus {
|
||||
private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
|
||||
private static final int MINIMUM_HEIGHT_SAMPLES = 64;
|
||||
private static final double LOW_HEIGHT_PERCENTILE = 0.01D;
|
||||
private static final double HIGH_HEIGHT_PERCENTILE = 0.99D;
|
||||
private static final double HEIGHT_RANGE_STEP_BLOCKS = 8D;
|
||||
private static final double MINIMUM_WATER_DEPTH_BLOCKS = 8D;
|
||||
private static final double MINIMUM_LAND_HEIGHT_BLOCKS = 16D;
|
||||
|
||||
private MapCensus() {
|
||||
}
|
||||
|
||||
static List<Share> count(VisionRenderController.Frame frame, ViewArea area, ToIntFunction<MapLabels.Label> colorOf) {
|
||||
Map<Short, long[]> counts = new HashMap<>();
|
||||
MapLabels[] labels = new MapLabels[1];
|
||||
long counted = visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
|
||||
labels[0] = tile.labels();
|
||||
counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
|
||||
});
|
||||
return shares(counts, labels[0], counted, colorOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heights on screen, ignoring the highest and lowest few percent so one spike doesn't wash out every colour.
|
||||
* Rounded to whole steps, so panning around doesn't recolour the map for every small change.
|
||||
*
|
||||
* @return the range, or null until enough of the map is drawn to tell
|
||||
*/
|
||||
static MapStyle.HeightRange heightRange(VisionRenderController.Frame frame, ViewArea area, double seaLevel) {
|
||||
DoubleList heights = new DoubleList();
|
||||
visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
|
||||
if (tile.hasHeights()) {
|
||||
heights.add(tile.heightAt(pixelX, pixelZ));
|
||||
}
|
||||
});
|
||||
if (heights.size() < MINIMUM_HEIGHT_SAMPLES) {
|
||||
return null;
|
||||
}
|
||||
double[] sorted = heights.sorted();
|
||||
double low = sorted[(int) ((sorted.length - 1) * LOW_HEIGHT_PERCENTILE)];
|
||||
double high = sorted[(int) ((sorted.length - 1) * HIGH_HEIGHT_PERCENTILE)];
|
||||
double lowest = Math.floor(Math.min(low, seaLevel - MINIMUM_WATER_DEPTH_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
|
||||
* HEIGHT_RANGE_STEP_BLOCKS;
|
||||
double highest = Math.ceil(Math.max(high, seaLevel + MINIMUM_LAND_HEIGHT_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
|
||||
* HEIGHT_RANGE_STEP_BLOCKS;
|
||||
return new MapStyle.HeightRange(lowest, seaLevel, highest);
|
||||
}
|
||||
|
||||
private static long visitVisiblePixels(VisionRenderController.Frame frame, ViewArea area, PixelVisitor visitor) {
|
||||
if (frame == null) {
|
||||
return 0L;
|
||||
}
|
||||
double blocksPerPixel = frame.renderBlocksPerPixel();
|
||||
int stride = countingStride(area.maximumX() - area.minimumX(), area.maximumZ() - area.minimumZ(), blocksPerPixel);
|
||||
double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
|
||||
long visited = 0L;
|
||||
for (VisionRenderController.VisibleTile visible : frame.tiles()) {
|
||||
MapTile tile = visible.tile();
|
||||
if (tile == null) {
|
||||
continue;
|
||||
}
|
||||
double originX = visible.tileX() * tileSpan;
|
||||
double originZ = visible.tileZ() * tileSpan;
|
||||
int firstX = firstPixel(area.minimumX() - originX, blocksPerPixel, stride, visible.tileX());
|
||||
int firstZ = firstPixel(area.minimumZ() - originZ, blocksPerPixel, stride, visible.tileZ());
|
||||
int endX = endPixel(area.maximumX() - originX, blocksPerPixel, tile.size());
|
||||
int endZ = endPixel(area.maximumZ() - originZ, blocksPerPixel, tile.size());
|
||||
for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) {
|
||||
for (int pixelX = firstX; pixelX < endX; pixelX += stride) {
|
||||
visitor.visit(tile, pixelX, pixelZ);
|
||||
visited++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
private static List<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
|
||||
ToIntFunction<MapLabels.Label> colorOf) {
|
||||
if (labels == null || counted == 0L) {
|
||||
return List.of();
|
||||
}
|
||||
List<Share> shares = new ArrayList<>(counts.size());
|
||||
for (Map.Entry<Short, long[]> entry : counts.entrySet()) {
|
||||
MapLabels.Label label = labels.label(entry.getKey());
|
||||
if (label != null) {
|
||||
shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted));
|
||||
}
|
||||
}
|
||||
shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name()));
|
||||
return List.copyOf(shares);
|
||||
}
|
||||
|
||||
// Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages.
|
||||
private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) {
|
||||
double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel);
|
||||
return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS)));
|
||||
}
|
||||
|
||||
// Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely.
|
||||
private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) {
|
||||
int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel));
|
||||
long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first;
|
||||
int misalignment = (int) Math.floorMod(globalPixel, (long) stride);
|
||||
return misalignment == 0 ? first : first + stride - misalignment;
|
||||
}
|
||||
|
||||
private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) {
|
||||
return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel));
|
||||
}
|
||||
|
||||
record Share(MapLabels.Label label, int color, double fraction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The world area the screen shows, in blocks.
|
||||
*/
|
||||
record ViewArea(double minimumX, double minimumZ, double maximumX, double maximumZ) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface PixelVisitor {
|
||||
void visit(MapTile tile, int pixelX, int pixelZ);
|
||||
}
|
||||
|
||||
private static final class DoubleList {
|
||||
private double[] values = new double[1024];
|
||||
private int size;
|
||||
|
||||
void add(double value) {
|
||||
if (size == values.length) {
|
||||
values = Arrays.copyOf(values, size * 2);
|
||||
}
|
||||
values[size++] = value;
|
||||
}
|
||||
|
||||
int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
double[] sorted() {
|
||||
double[] sorted = Arrays.copyOf(values, size);
|
||||
Arrays.sort(sorted);
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/*
|
||||
* 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.MapHoverProbe.PickStep;
|
||||
import com.volmit.iris.core.gui.NoisePickSpread.Band;
|
||||
import com.volmit.iris.core.gui.components.MapLabels;
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
import com.volmit.iris.core.loader.IrisRegistrant;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.Shape;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The card that follows the cursor, laid out like IntelliJ's documentation popup: the biome as the title, the steps
|
||||
* that picked it as a small tree, and with Shift the terrain, location and pack files below.
|
||||
*/
|
||||
record MapHoverCard(Header header, String subtitle, String pathTitle, List<PickStep> path, boolean showsBands,
|
||||
List<Group> groups, List<Hint> hints) {
|
||||
private static final int MINIMUM_CONTENT_WIDTH = 260;
|
||||
private static final int PADDING = 12;
|
||||
private static final int HEADER_TOP = 10;
|
||||
private static final int HEADER_ICON_SIZE = 14;
|
||||
private static final int NODE_ICON_SIZE = 12;
|
||||
private static final int ICON_GAP = 8;
|
||||
private static final int KIND_GAP = 16;
|
||||
private static final int GROUP_GAP = 8;
|
||||
private static final int GROUP_TITLE_GAP = 4;
|
||||
private static final int NODE_HEIGHT = 24;
|
||||
private static final int NODE_INDENT = 18;
|
||||
private static final int CHIP_GAP = 16;
|
||||
private static final int CHIP_PADDING_X = 5;
|
||||
private static final int CHIP_PADDING_Y = 1;
|
||||
private static final int BAND_HEIGHT = 4;
|
||||
private static final int BAND_GAP = 6;
|
||||
private static final int MARKER_WIDTH = 2;
|
||||
private static final int MARKER_HEIGHT = 10;
|
||||
private static final int COLUMN_GAP = 14;
|
||||
private static final int ROW_GAP = 3;
|
||||
private static final int BOTTOM_PADDING = 10;
|
||||
private static final int FOOTER_PADDING_Y = 7;
|
||||
private static final int KEY_PADDING_X = 4;
|
||||
private static final int KEY_GAP = 4;
|
||||
private static final int HINT_GAP = 14;
|
||||
private static final Color ICON_EDGE = new ThemeColor(new Color(0, 0, 0, 38), new Color(255, 255, 255, 38));
|
||||
private static final Color MARKER_EDGE = new Color(0, 0, 0, 200);
|
||||
|
||||
/**
|
||||
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
|
||||
* @param region the region the drawn tile shows
|
||||
* @param details the probed facts about the spot, or null until they arrive
|
||||
* @param detailed whether Shift is held, which trades the noise bands for terrain, location and pack files
|
||||
*/
|
||||
static MapHoverCard of(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
|
||||
RenderType type, boolean detailed, double worldX, double worldZ) {
|
||||
List<PickStep> path = details == null ? List.of() : details.pickPath();
|
||||
IrisBiome biome = cardBiome(subject, details, type);
|
||||
List<Group> groups = new ArrayList<>();
|
||||
if (detailed) {
|
||||
addTerrainGroup(groups, details, type);
|
||||
groups.add(locationGroup(worldX, worldZ));
|
||||
addFilesGroup(groups, biome, details != null ? details.region() : region == null ? null : region.subject());
|
||||
}
|
||||
return new MapHoverCard(header(subject, biome, type, path), subtitle(details), pathTitle(path), path, !detailed,
|
||||
groups, hints(type, detailed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the card with its top left corner as close to the given point as fits inside the bounds.
|
||||
*/
|
||||
void paint(Graphics2D canvas, int anchorX, int anchorY, int boundsWidth, int boundsHeight) {
|
||||
Graphics2D card = (Graphics2D) canvas.create();
|
||||
card.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
card.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int padding = StudioTheme.scale(PADDING);
|
||||
int contentWidth = contentWidth(card);
|
||||
int width = contentWidth + padding * 2;
|
||||
int footerHeight = footerHeight(card);
|
||||
int height = StudioTheme.scale(HEADER_TOP) + headerHeight(card) + pathHeight(card) + groupsHeight(card)
|
||||
+ StudioTheme.scale(BOTTOM_PADDING) + footerHeight;
|
||||
int x = Math.max(padding, Math.min(boundsWidth - width - padding, anchorX));
|
||||
int y = Math.max(padding, Math.min(boundsHeight - height - padding, anchorY));
|
||||
StudioOverlay.drawCard(card, x, y, width, height, footerHeight);
|
||||
|
||||
int left = x + padding;
|
||||
int top = y + StudioTheme.scale(HEADER_TOP);
|
||||
top = drawHeader(card, left, top, contentWidth);
|
||||
top = drawPath(card, left, top, contentWidth);
|
||||
drawGroups(card, left, top);
|
||||
drawFooter(card, left, y + height - footerHeight);
|
||||
card.dispose();
|
||||
}
|
||||
|
||||
private int contentWidth(Graphics2D canvas) {
|
||||
int width = Math.max(headerWidth(canvas), pathWidth(canvas));
|
||||
width = Math.max(width, groupsWidth(canvas));
|
||||
width = Math.max(width, footerWidth(canvas));
|
||||
return Math.max(StudioTheme.scale(MINIMUM_CONTENT_WIDTH), width);
|
||||
}
|
||||
|
||||
// Header: the biome's colour, its name in bold with its kind on the right, and the ground's height below.
|
||||
private int headerTextOffset() {
|
||||
return header.color() == null ? 0 : StudioTheme.scale(HEADER_ICON_SIZE + ICON_GAP);
|
||||
}
|
||||
|
||||
private int headerWidth(Graphics2D canvas) {
|
||||
int width = headerTextOffset() + canvas.getFontMetrics(StudioTheme.boldFont()).stringWidth(header.name());
|
||||
if (header.kind() != null) {
|
||||
width += StudioTheme.scale(KIND_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(header.kind());
|
||||
}
|
||||
if (subtitle != null) {
|
||||
width = Math.max(width, headerTextOffset() + canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(subtitle));
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
private int headerHeight(Graphics2D canvas) {
|
||||
int height = canvas.getFontMetrics(StudioTheme.boldFont()).getHeight();
|
||||
if (subtitle != null) {
|
||||
height += canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private int drawHeader(Graphics2D canvas, int left, int top, int contentWidth) {
|
||||
FontMetrics bold = canvas.getFontMetrics(StudioTheme.boldFont());
|
||||
if (header.color() != null) {
|
||||
int iconSize = StudioTheme.scale(HEADER_ICON_SIZE);
|
||||
drawIcon(canvas, header.color(), left, top + (bold.getHeight() - iconSize) / 2, iconSize);
|
||||
}
|
||||
int textLeft = left + headerTextOffset();
|
||||
int baseline = top + bold.getAscent();
|
||||
drawText(canvas, header.name(), StudioTheme.boldFont(), StudioTheme.TEXT, textLeft, baseline);
|
||||
if (header.kind() != null) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
drawText(canvas, header.kind(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT,
|
||||
left + contentWidth - small.stringWidth(header.kind()), baseline);
|
||||
}
|
||||
top += bold.getHeight();
|
||||
if (subtitle != null) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
drawText(canvas, subtitle, StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, textLeft, top + small.getAscent());
|
||||
top += small.getHeight();
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// Path: one indented row per step, its value as a chip on the right and, without Shift, the noise bands below it.
|
||||
private int pathWidth(Graphics2D canvas) {
|
||||
int width = 0;
|
||||
for (int level = 0; level < path.size(); level++) {
|
||||
PickStep step = path.get(level);
|
||||
int row = nodeTextLeft(level) + canvas.getFontMetrics(StudioTheme.font()).stringWidth(step.picked().name() + " ")
|
||||
+ canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(step.reason());
|
||||
if (step.value() != null) {
|
||||
row += StudioTheme.scale(CHIP_GAP) + chipWidth(canvas, step.value());
|
||||
}
|
||||
width = Math.max(width, row);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
private int pathHeight(Graphics2D canvas) {
|
||||
if (path.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int height = groupTitleHeight(canvas);
|
||||
for (PickStep step : path) {
|
||||
height += nodeHeight(step);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private int nodeHeight(PickStep step) {
|
||||
int height = StudioTheme.scale(NODE_HEIGHT);
|
||||
return showsBands && step.showsBands() ? height + StudioTheme.scale(BAND_HEIGHT + BAND_GAP) : height;
|
||||
}
|
||||
|
||||
private static int nodeTextLeft(int level) {
|
||||
return StudioTheme.scale(level * NODE_INDENT + NODE_ICON_SIZE + ICON_GAP);
|
||||
}
|
||||
|
||||
private int drawPath(Graphics2D canvas, int left, int top, int contentWidth) {
|
||||
if (path.isEmpty()) {
|
||||
return top;
|
||||
}
|
||||
top = drawGroupTitle(canvas, pathTitle, left, top);
|
||||
for (int level = 0; level < path.size(); level++) {
|
||||
PickStep step = path.get(level);
|
||||
drawNode(canvas, step, left, top, level, contentWidth);
|
||||
if (showsBands && step.showsBands()) {
|
||||
int bandsLeft = left + nodeTextLeft(level);
|
||||
drawBands(canvas, step, bandsLeft, top + StudioTheme.scale(NODE_HEIGHT), left + contentWidth - bandsLeft);
|
||||
}
|
||||
top += nodeHeight(step);
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private void drawNode(Graphics2D canvas, PickStep step, int left, int top, int level, int contentWidth) {
|
||||
int rowHeight = StudioTheme.scale(NODE_HEIGHT);
|
||||
int iconSize = StudioTheme.scale(NODE_ICON_SIZE);
|
||||
drawIcon(canvas, step.picked().color(), left + StudioTheme.scale(level * NODE_INDENT), top + (rowHeight - iconSize) / 2,
|
||||
iconSize);
|
||||
FontMetrics regular = canvas.getFontMetrics(StudioTheme.font());
|
||||
int baseline = top + (rowHeight - regular.getHeight()) / 2 + regular.getAscent();
|
||||
int textLeft = left + nodeTextLeft(level);
|
||||
String name = step.picked().name() + " ";
|
||||
drawText(canvas, name, StudioTheme.font(), StudioTheme.TEXT, textLeft, baseline);
|
||||
drawText(canvas, step.reason(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, textLeft + regular.stringWidth(name),
|
||||
baseline);
|
||||
if (step.value() != null) {
|
||||
drawChip(canvas, step.value(), left + contentWidth - chipWidth(canvas, step.value()), top, rowHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// A strip with each option's band of noise in its colour, and a marker where this spot's noise falls.
|
||||
private static void drawBands(Graphics2D canvas, PickStep step, int left, int top, int width) {
|
||||
int height = StudioTheme.scale(BAND_HEIGHT);
|
||||
Shape clip = canvas.getClip();
|
||||
canvas.clip(new RoundRectangle2D.Double(left, top, width, height, height, height));
|
||||
for (Band band : step.bands()) {
|
||||
int from = left + (int) Math.round(band.fromNoise() * width);
|
||||
int to = left + (int) Math.round(band.toNoise() * width);
|
||||
canvas.setColor(new Color(band.option().color()));
|
||||
canvas.fillRect(from, top, Math.max(1, to - from), height);
|
||||
}
|
||||
canvas.setClip(clip);
|
||||
int markerWidth = StudioTheme.scale(MARKER_WIDTH);
|
||||
int markerHeight = StudioTheme.scale(MARKER_HEIGHT);
|
||||
int markerX = left + (int) Math.round(Math.clamp(step.noise(), 0D, 1D) * (width - markerWidth));
|
||||
int markerY = top + (height - markerHeight) / 2;
|
||||
canvas.setColor(MARKER_EDGE);
|
||||
canvas.fillRect(markerX - 1, markerY - 1, markerWidth + 2, markerHeight + 2);
|
||||
canvas.setColor(Color.WHITE);
|
||||
canvas.fillRect(markerX, markerY, markerWidth, markerHeight);
|
||||
}
|
||||
|
||||
private static Font chipFont() {
|
||||
return StudioTheme.monospacedFont().deriveFont(StudioTheme.smallFont().getSize2D());
|
||||
}
|
||||
|
||||
private static int chipWidth(Graphics2D canvas, String value) {
|
||||
return canvas.getFontMetrics(chipFont()).stringWidth(value) + StudioTheme.scale(CHIP_PADDING_X) * 2;
|
||||
}
|
||||
|
||||
// Styled like an inlay hint in IntelliJ's editor.
|
||||
private static void drawChip(Graphics2D canvas, String value, int left, int rowTop, int rowHeight) {
|
||||
FontMetrics metrics = canvas.getFontMetrics(chipFont());
|
||||
int height = metrics.getHeight() + StudioTheme.scale(CHIP_PADDING_Y) * 2;
|
||||
int top = rowTop + (rowHeight - height) / 2;
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
int arc = StudioTheme.scale(8);
|
||||
canvas.fillRoundRect(left, top, chipWidth(canvas, value), height, arc, arc);
|
||||
drawText(canvas, value, chipFont(), StudioTheme.SECONDARY_TEXT, left + StudioTheme.scale(CHIP_PADDING_X),
|
||||
top + StudioTheme.scale(CHIP_PADDING_Y) + metrics.getAscent());
|
||||
}
|
||||
|
||||
private static void drawIcon(Graphics2D canvas, int rgb, int left, int top, int size) {
|
||||
int arc = Math.max(2, size / 2);
|
||||
canvas.setColor(new Color(rgb));
|
||||
canvas.fillRoundRect(left, top, size, size, arc, arc);
|
||||
canvas.setColor(ICON_EDGE);
|
||||
canvas.drawRoundRect(left, top, size - 1, size - 1, arc, arc);
|
||||
}
|
||||
|
||||
// Groups: sentence-case titles over rows of muted labels and values, all labels in one column.
|
||||
private int labelColumnWidth(Graphics2D canvas) {
|
||||
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
|
||||
return groups.stream().flatMap(group -> group.rows().stream())
|
||||
.mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0);
|
||||
}
|
||||
|
||||
private int groupsWidth(Graphics2D canvas) {
|
||||
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
|
||||
int valueWidth = groups.stream().flatMap(group -> group.rows().stream())
|
||||
.mapToInt(row -> metrics.stringWidth(row.value())).max().orElse(0);
|
||||
return labelColumnWidth(canvas) + StudioTheme.scale(COLUMN_GAP) + valueWidth;
|
||||
}
|
||||
|
||||
private int groupsHeight(Graphics2D canvas) {
|
||||
int rowHeight = canvas.getFontMetrics(StudioTheme.font()).getHeight() + StudioTheme.scale(ROW_GAP);
|
||||
return groups.stream().mapToInt(group -> groupTitleHeight(canvas) + group.rows().size() * rowHeight).sum();
|
||||
}
|
||||
|
||||
private void drawGroups(Graphics2D canvas, int left, int top) {
|
||||
FontMetrics regular = canvas.getFontMetrics(StudioTheme.font());
|
||||
int valueLeft = left + labelColumnWidth(canvas) + StudioTheme.scale(COLUMN_GAP);
|
||||
for (Group group : groups) {
|
||||
top = drawGroupTitle(canvas, group.title(), left, top);
|
||||
for (Row row : group.rows()) {
|
||||
int baseline = top + regular.getAscent();
|
||||
drawText(canvas, row.label(), StudioTheme.font(), StudioTheme.SECONDARY_TEXT, left, baseline);
|
||||
drawText(canvas, row.value(), StudioTheme.font(), row.link() ? StudioTheme.LINK : StudioTheme.TEXT, valueLeft,
|
||||
baseline);
|
||||
top += regular.getHeight() + StudioTheme.scale(ROW_GAP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int groupTitleHeight(Graphics2D canvas) {
|
||||
return StudioTheme.scale(GROUP_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight()
|
||||
+ StudioTheme.scale(GROUP_TITLE_GAP);
|
||||
}
|
||||
|
||||
private static int drawGroupTitle(Graphics2D canvas, String title, int left, int top) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
top += StudioTheme.scale(GROUP_GAP);
|
||||
drawText(canvas, title, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, top + small.getAscent());
|
||||
return top + small.getHeight() + StudioTheme.scale(GROUP_TITLE_GAP);
|
||||
}
|
||||
|
||||
// Footer: the shortcuts as key caps, each followed by what it does.
|
||||
private int footerWidth(Graphics2D canvas) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
int width = 0;
|
||||
for (Hint hint : hints) {
|
||||
width += keyWidth(small, hint.key()) + StudioTheme.scale(KEY_GAP) + small.stringWidth(hint.action());
|
||||
}
|
||||
return width + StudioTheme.scale(HINT_GAP) * Math.max(0, hints.size() - 1);
|
||||
}
|
||||
|
||||
private static int footerHeight(Graphics2D canvas) {
|
||||
return canvas.getFontMetrics(StudioTheme.smallFont()).getHeight() + StudioTheme.scale(FOOTER_PADDING_Y) * 2;
|
||||
}
|
||||
|
||||
private static int keyWidth(FontMetrics small, String key) {
|
||||
return small.stringWidth(key) + StudioTheme.scale(KEY_PADDING_X) * 2;
|
||||
}
|
||||
|
||||
private void drawFooter(Graphics2D canvas, int left, int top) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
int textTop = top + StudioTheme.scale(FOOTER_PADDING_Y);
|
||||
int baseline = textTop + small.getAscent();
|
||||
int arc = StudioTheme.scale(6);
|
||||
for (Hint hint : hints) {
|
||||
int keyWidth = keyWidth(small, hint.key());
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(left, textTop, keyWidth, small.getHeight(), arc, arc);
|
||||
drawText(canvas, hint.key(), StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT,
|
||||
left + StudioTheme.scale(KEY_PADDING_X), baseline);
|
||||
left += keyWidth + StudioTheme.scale(KEY_GAP);
|
||||
drawText(canvas, hint.action(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, baseline);
|
||||
left += small.stringWidth(hint.action()) + StudioTheme.scale(HINT_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawText(Graphics2D canvas, String text, Font font, Color color, int left, int baseline) {
|
||||
canvas.setFont(font);
|
||||
canvas.setColor(color);
|
||||
canvas.drawString(text, left, baseline);
|
||||
}
|
||||
|
||||
// The card is about a biome; the region view's tiles only know the region until the probe finds the biome.
|
||||
private static IrisBiome cardBiome(MapLabels.Label subject, MapHoverProbe.Details details, RenderType type) {
|
||||
if (subject != null && subject.subject() instanceof IrisBiome biome) {
|
||||
return biome;
|
||||
}
|
||||
if (details == null) {
|
||||
return null;
|
||||
}
|
||||
return type == RenderType.CAVE_LAND ? details.caveBiome() : details.surfaceBiome();
|
||||
}
|
||||
|
||||
private static Header header(MapLabels.Label subject, IrisBiome biome, RenderType type, List<PickStep> path) {
|
||||
if (biome != null) {
|
||||
return new Header(biome.getName(), colorOf(biome, path), type == RenderType.CAVE_LAND ? "Cave biome" : "Biome");
|
||||
}
|
||||
if (subject != null) {
|
||||
return new Header(subject.name(), null, subject.kind() == MapLabels.Kind.REGION ? "Region" : "Biome");
|
||||
}
|
||||
return new Header("Drawing…", null, null);
|
||||
}
|
||||
|
||||
private static Integer colorOf(IrisBiome biome, List<PickStep> path) {
|
||||
return path.stream()
|
||||
.filter(step -> step.picked().key().equals(biome.getLoadKey()))
|
||||
.reduce((first, last) -> last)
|
||||
.map(step -> step.picked().color())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String subtitle(MapHoverProbe.Details details) {
|
||||
if (details == null) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(details.height()) + " · " + heightAboveSea(details.height() - details.fluidHeight()) + " · slope "
|
||||
+ Math.round(details.slopeDegrees()) + "°";
|
||||
}
|
||||
|
||||
private static String pathTitle(List<PickStep> path) {
|
||||
return path.size() > 1 ? "Picked by" : "Region";
|
||||
}
|
||||
|
||||
private static void addTerrainGroup(List<Group> groups, MapHoverProbe.Details details, RenderType type) {
|
||||
if (details == null) {
|
||||
return;
|
||||
}
|
||||
List<Row> rows = new ArrayList<>();
|
||||
IrisBiome surface = details.surfaceBiome();
|
||||
IrisBiome cave = details.caveBiome();
|
||||
if (type == RenderType.CAVE_LAND && surface != null) {
|
||||
rows.add(new Row("Surface biome", surface.getName(), false));
|
||||
} else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) {
|
||||
rows.add(new Row("Cave biome", cave.getName(), false));
|
||||
}
|
||||
if (details.vanillaBiome() != null) {
|
||||
rows.add(new Row("Minecraft", details.vanillaBiome().replace('_', ' '), false));
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
groups.add(new Group("Terrain", rows));
|
||||
}
|
||||
}
|
||||
|
||||
private static Group locationGroup(double worldX, double worldZ) {
|
||||
int blockX = MapCanvas.floorWorldCoordinate(worldX);
|
||||
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
|
||||
return new Group("Location", List.of(
|
||||
new Row("Block", blockX + ", " + blockZ, false),
|
||||
new Row("Chunk", (blockX >> 4) + ", " + (blockZ >> 4), false),
|
||||
new Row("Region file", "r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca", false)));
|
||||
}
|
||||
|
||||
private static void addFilesGroup(List<Group> groups, IrisBiome biome, Object region) {
|
||||
List<Row> rows = new ArrayList<>();
|
||||
if (biome != null) {
|
||||
rows.add(new Row("Biome", packPath(biome), true));
|
||||
}
|
||||
if (region instanceof IrisRegistrant registrant) {
|
||||
rows.add(new Row("Region", packPath(registrant), true));
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
groups.add(new Group("Files", rows));
|
||||
}
|
||||
}
|
||||
|
||||
// The key is the path under the type's folder, so folder, key and extension give the file within the pack.
|
||||
private static String packPath(IrisRegistrant registrant) {
|
||||
File file = registrant.getLoadFile();
|
||||
String fileName = file == null ? "" : file.getName();
|
||||
int extensionStart = fileName.lastIndexOf('.');
|
||||
String extension = extensionStart < 0 ? "" : fileName.substring(extensionStart);
|
||||
return registrant.getFolderName() + "/" + registrant.getLoadKey() + extension;
|
||||
}
|
||||
|
||||
private static List<Hint> hints(RenderType type, boolean detailed) {
|
||||
List<Hint> hints = new ArrayList<>();
|
||||
if (!detailed) {
|
||||
hints.add(new Hint("Shift", "More"));
|
||||
}
|
||||
hints.add(new Hint("H", "Highlight"));
|
||||
if (type != RenderType.CONTINENT) {
|
||||
hints.add(new Hint("Alt+Click", "Open file"));
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
private static String heightAboveSea(double height) {
|
||||
long rounded = Math.round(height);
|
||||
return rounded < 0 ? -rounded + " below sea" : rounded + " above sea";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param color the biome's map colour, or null when no step of the path tells it
|
||||
* @param kind what the title names, like "Biome", or null while drawing
|
||||
*/
|
||||
record Header(String name, Integer color, String kind) {
|
||||
}
|
||||
|
||||
record Group(String title, List<Row> rows) {
|
||||
}
|
||||
|
||||
record Row(String label, String value, boolean link) {
|
||||
}
|
||||
|
||||
record Hint(String key, String action) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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.NoisePickSpread.Band;
|
||||
import com.volmit.iris.core.gui.NoisePickSpread.Option;
|
||||
import com.volmit.iris.engine.HybridBiomeSelector;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread.
|
||||
*/
|
||||
final class MapHoverProbe {
|
||||
// IrisComplex measures slope as the height change over this many blocks.
|
||||
private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D;
|
||||
|
||||
private MapHoverProbe() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param previewRegionKey the region the region preview treats every spot as part of, or null for the real world
|
||||
* @param explainBiomePick whether the card shows the surface biome, so the whole way to it is worth looking up;
|
||||
* otherwise only how the region was picked
|
||||
*/
|
||||
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey, boolean explainBiomePick) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
IrisRegion previewRegion = previewRegionKey == null ? null : engine.getData().getRegionLoader().load(previewRegionKey);
|
||||
IrisRegion region = previewRegion != null ? previewRegion : complex.getRegionStream().get(worldX, worldZ);
|
||||
IrisComplex.BiomePick surfacePick = complex.surfacePickAsIfIn(region, worldX, worldZ);
|
||||
Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ);
|
||||
double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
return new Details(
|
||||
worldX,
|
||||
worldZ,
|
||||
surfacePick.biome(),
|
||||
complex.getCaveBiomeStream().get(worldX, worldZ),
|
||||
region,
|
||||
vanillaBiome == null ? null : vanillaBiome.getKey().getKey(),
|
||||
complex.getHeightStream().getDouble(worldX, worldZ),
|
||||
dimension == null ? 0D : dimension.getFluidHeight(),
|
||||
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)),
|
||||
pickPath(engine, region, previewRegion != null, explainBiomePick ? surfacePick : null, worldX, worldZ)
|
||||
);
|
||||
}
|
||||
|
||||
// The region preview chose the region itself; the real map picks it by the region noise.
|
||||
private static List<PickStep> pickPath(Engine engine, IrisRegion region, boolean previewed,
|
||||
IrisComplex.BiomePick surfacePick, double x, double z) {
|
||||
List<PickStep> path = new ArrayList<>();
|
||||
path.add(previewed ? previewedRegionStep(engine, region) : regionStep(engine, region, x, z));
|
||||
if (surfacePick == null || surfacePick.type() == null || engine.getComplex().getFocusBiome() != null) {
|
||||
return path;
|
||||
}
|
||||
PickStep biomeStep = engine.getComplex().isHybridTerrain()
|
||||
? heightStep(engine, region, surfacePick.type(), x, z)
|
||||
: noiseStep(engine, region, surfacePick.type(), x, z);
|
||||
if (biomeStep == null) {
|
||||
return path;
|
||||
}
|
||||
path.add(biomeStep);
|
||||
if (!biomeStep.picked().key().equals(surfacePick.biome().getLoadKey())) {
|
||||
path.add(new PickStep(PickMeasuring.biomeOption(engine, surfacePick.biome()), "child", null, List.of(), Double.NaN));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static PickStep regionStep(Engine engine, IrisRegion region, double x, double z) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
double noise = complex.getRegionStyleStream().get(x, z);
|
||||
List<Option> regions = complex.noisePickedRegions().stream()
|
||||
.map(candidate -> PickMeasuring.regionOption(engine, candidate))
|
||||
.toList();
|
||||
return new PickStep(PickMeasuring.regionOption(engine, region), "region", noiseText(noise),
|
||||
NoisePickSpread.splitNoise(regions, complex.isLegacyRarity()), noise);
|
||||
}
|
||||
|
||||
private static PickStep previewedRegionStep(Engine engine, IrisRegion region) {
|
||||
return new PickStep(PickMeasuring.regionOption(engine, region), "previewed", null, List.of(), Double.NaN);
|
||||
}
|
||||
|
||||
private static PickStep noiseStep(Engine engine, IrisRegion region, InferredType type, double x, double z) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
double noise = complex.biomePickNoise(region, type, x, z);
|
||||
List<Option> biomes = complex.noisePickedBiomes(region, type).stream()
|
||||
.map(biome -> PickMeasuring.biomeOption(engine, biome))
|
||||
.toList();
|
||||
IrisBiome picked = complex.biomePickedByNoise(region, type, x, z);
|
||||
return new PickStep(PickMeasuring.biomeOption(engine, picked), typeName(type), noiseText(noise),
|
||||
NoisePickSpread.splitNoise(biomes, complex.isLegacyRarity()), noise);
|
||||
}
|
||||
|
||||
// HYBRID terrain picks by the height a spot is placed at, and only competes where biome ranges share that height.
|
||||
private static PickStep heightStep(Engine engine, IrisRegion region, InferredType type, double x, double z) {
|
||||
HybridBiomeSelector.Choice choice = engine.getComplex().explainHybridChoice(region, type, x, z);
|
||||
if (choice == null) {
|
||||
return null;
|
||||
}
|
||||
return new PickStep(PickMeasuring.biomeOption(engine, choice.biome()), typeName(type) + ", " + competitionText(choice),
|
||||
signedHeightText(choice.placementHeight()), List.of(), Double.NaN);
|
||||
}
|
||||
|
||||
private static String competitionText(HybridBiomeSelector.Choice choice) {
|
||||
if (choice.fallback()) {
|
||||
return "nearest range";
|
||||
}
|
||||
if (choice.rivalCount() == 0) {
|
||||
return "only fit";
|
||||
}
|
||||
return "beat " + choice.rivalCount();
|
||||
}
|
||||
|
||||
private static String typeName(InferredType type) {
|
||||
return type.name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String noiseText(double noise) {
|
||||
return String.format(Locale.ROOT, "%.3f", noise);
|
||||
}
|
||||
|
||||
private static String signedHeightText(double heightAboveSea) {
|
||||
long rounded = Math.round(heightAboveSea);
|
||||
return rounded < 0 ? "−" + -rounded : "+" + rounded;
|
||||
}
|
||||
|
||||
record Details(
|
||||
double worldX,
|
||||
double worldZ,
|
||||
IrisBiome surfaceBiome,
|
||||
IrisBiome caveBiome,
|
||||
IrisRegion region,
|
||||
String vanillaBiome,
|
||||
double height,
|
||||
double fluidHeight,
|
||||
double slopeDegrees,
|
||||
List<PickStep> pickPath
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One step on the way to the spot's biome, starting with its region.
|
||||
*
|
||||
* @param reason what picked it: its kind, the biome list the noise picked from, or "child"
|
||||
* @param value the noise value or placement height that picked it, or null
|
||||
* @param bands the noise bands of all the options the noise picked from, empty when no noise picked it
|
||||
*/
|
||||
record PickStep(Option picked, String reason, String value, List<Band> bands, double noise) {
|
||||
boolean showsBands() {
|
||||
return !bands.isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.components.MapStyle;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map.
|
||||
*/
|
||||
final class MapLegendPanel extends JPanel {
|
||||
private static final long serialVersionUID = 4471297416386034128L;
|
||||
private static final int PANEL_WIDTH = 250;
|
||||
private static final int ROW_HEIGHT = 26;
|
||||
private static final int SWATCH_SIZE = 12;
|
||||
private static final int SWATCH_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
private static final int PERCENT_WIDTH = 42;
|
||||
private static final String DRAWING_MESSAGE = "Drawing the map…";
|
||||
|
||||
private final JLabel title = new JLabel();
|
||||
private final DefaultListModel<MapCensus.Share> rows = new DefaultListModel<>();
|
||||
private final JList<MapCensus.Share> list = new JList<>(rows) {
|
||||
// Long names shorten with an ellipsis instead of pushing the percentages out of sight.
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
private final Set<String> sharedNames = new HashSet<>();
|
||||
private MapStyle.Highlight highlight;
|
||||
|
||||
MapLegendPanel(Consumer<MapCensus.Share> onRowClicked) {
|
||||
super(new BorderLayout());
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
|
||||
title.setFont(StudioTheme.sectionFont());
|
||||
title.setForeground(StudioTheme.MUTED_TEXT);
|
||||
title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16));
|
||||
add(title, BorderLayout.NORTH);
|
||||
|
||||
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
list.setFocusable(false);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new ShareRenderer());
|
||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
MapCensus.Share share = shareAt(event.getPoint());
|
||||
if (share != null) {
|
||||
onRowClicked.accept(share);
|
||||
}
|
||||
}
|
||||
});
|
||||
add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
|
||||
JLabel hint = new JLabel("Click to highlight · Esc clears");
|
||||
hint.setFont(StudioTheme.smallFont());
|
||||
hint.setForeground(StudioTheme.MUTED_TEXT);
|
||||
hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16));
|
||||
add(hint, BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
void show(String subjectTitle, List<MapCensus.Share> shares, MapStyle.Highlight nextHighlight) {
|
||||
title.setText(subjectTitle.toUpperCase(Locale.ROOT));
|
||||
highlight = nextHighlight;
|
||||
rememberSharedNames(shares);
|
||||
if (!sameRows(shares)) {
|
||||
rows.clear();
|
||||
rows.addAll(shares);
|
||||
}
|
||||
list.repaint();
|
||||
}
|
||||
|
||||
// Two biomes can share a display name; those rows show their key so they can be told apart.
|
||||
private void rememberSharedNames(List<MapCensus.Share> shares) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
sharedNames.clear();
|
||||
for (MapCensus.Share share : shares) {
|
||||
if (!seen.add(share.label().name())) {
|
||||
sharedNames.add(share.label().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String rowText(MapCensus.Share share) {
|
||||
String name = share.label().name();
|
||||
return sharedNames.contains(name) ? name + " (" + share.label().key() + ")" : name;
|
||||
}
|
||||
|
||||
private boolean sameRows(List<MapCensus.Share> shares) {
|
||||
if (rows.size() != shares.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < shares.size(); index++) {
|
||||
if (!rows.get(index).equals(shares.get(index))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private MapCensus.Share shareAt(Point point) {
|
||||
int index = list.locationToIndex(point);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
Rectangle bounds = list.getCellBounds(index, index);
|
||||
return bounds != null && bounds.contains(point) ? rows.get(index) : null;
|
||||
}
|
||||
|
||||
private boolean isHighlighted(MapCensus.Share share) {
|
||||
return highlight != null
|
||||
&& highlight.kind() == share.label().kind()
|
||||
&& highlight.key().equals(share.label().key());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintChildren(Graphics graphics) {
|
||||
super.paintChildren(graphics);
|
||||
if (!rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16),
|
||||
title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private final class ShareRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = -2350212934617052618L;
|
||||
private Color swatch = Color.BLACK;
|
||||
private String percent = "";
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
MapCensus.Share share = (MapCensus.Share) value;
|
||||
super.getListCellRendererComponent(owner, rowText(share), index, isHighlighted(share), false);
|
||||
setFont(StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH));
|
||||
setToolTipText(share.label().key());
|
||||
swatch = new Color(share.color());
|
||||
percent = formatPercent(share.fraction());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int size = StudioTheme.scale(SWATCH_SIZE);
|
||||
int swatchX = StudioTheme.scale(ROW_PADDING);
|
||||
int swatchY = (getHeight() - size) / 2;
|
||||
int arc = StudioTheme.scale(4);
|
||||
canvas.setColor(swatch);
|
||||
canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String formatPercent(double fraction) {
|
||||
double percent = fraction * 100D;
|
||||
if (percent < 0.1D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return percent < 10D
|
||||
? String.format(Locale.ROOT, "%.1f%%", percent)
|
||||
: String.format(Locale.ROOT, "%.0f%%", percent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.components.RenderType;
|
||||
|
||||
import java.awt.event.KeyEvent;
|
||||
|
||||
/**
|
||||
* Everything the map window can show, in the order and groups of its view list.
|
||||
*/
|
||||
enum MapView {
|
||||
BIOMES("Biomes", "The biome at each spot, in the biome's colour", Group.WORLD, RenderType.BIOME, KeyEvent.VK_1),
|
||||
REGIONS("Regions", "The region each spot belongs to; pick one to see which biome covers which height",
|
||||
Group.WORLD, RenderType.REGION, KeyEvent.VK_2),
|
||||
HEIGHT("Height", "Terrain height, from deep water to snowy peaks", Group.WORLD, RenderType.HEIGHT, KeyEvent.VK_3),
|
||||
CONTINENTS("Continents", "Land, coast or sea: where hybrid terrain ends up, or each biome's first height generator otherwise",
|
||||
Group.WORLD, RenderType.CONTINENT, KeyEvent.VK_4),
|
||||
LAND_BIOMES("Land biomes", "The land biome each spot would get, also where the sea is",
|
||||
Group.BIOME_STREAMS, RenderType.BIOME_LAND, KeyEvent.VK_5),
|
||||
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
|
||||
Group.BIOME_STREAMS, RenderType.BIOME_SEA, KeyEvent.VK_6),
|
||||
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, RenderType.CAVE_LAND, KeyEvent.VK_7),
|
||||
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
|
||||
Group.BLOCKS, null, KeyEvent.VK_P),
|
||||
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
|
||||
Group.BLOCKS, null, KeyEvent.VK_O),
|
||||
PROCEDURAL("Procedural", "Trees, rock formations and more that Iris generates from settings, every variant in 3D",
|
||||
Group.BLOCKS, null, KeyEvent.VK_T),
|
||||
NOISE("Noise", "The pack's noise generators and Iris' built-in noise styles",
|
||||
Group.GENERATORS, null, KeyEvent.VK_N);
|
||||
|
||||
private final String label;
|
||||
private final String description;
|
||||
private final Group group;
|
||||
private final RenderType renderType;
|
||||
private final int shortcutKey;
|
||||
|
||||
MapView(String label, String description, Group group, RenderType renderType, int shortcutKey) {
|
||||
this.label = label;
|
||||
this.description = description;
|
||||
this.group = group;
|
||||
this.renderType = renderType;
|
||||
this.shortcutKey = shortcutKey;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
Group group() {
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile renderer's mode for this view, or null for the palette, object, procedural and noise views, which draw themselves.
|
||||
*/
|
||||
RenderType renderType() {
|
||||
return renderType;
|
||||
}
|
||||
|
||||
int shortcutKey() {
|
||||
return shortcutKey;
|
||||
}
|
||||
|
||||
String shortcutText() {
|
||||
return KeyEvent.getKeyText(shortcutKey);
|
||||
}
|
||||
|
||||
boolean isTileMap() {
|
||||
return renderType != null;
|
||||
}
|
||||
|
||||
static MapView forShortcut(int keyCode) {
|
||||
for (MapView view : values()) {
|
||||
if (view.shortcutKey == keyCode) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
enum Group {
|
||||
WORLD("World"),
|
||||
BIOME_STREAMS("Biome layers"),
|
||||
BLOCKS("Blocks"),
|
||||
GENERATORS("Generators");
|
||||
|
||||
private final String label;
|
||||
|
||||
Group(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.formdev.flatlaf.extras.FlatSVGIcon;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The icon of each map view, taken from IntelliJ's new UI icon set (see the NOTICE next to the files).
|
||||
*/
|
||||
final class MapViewIcon {
|
||||
private static final String ICON_FOLDER = "iris/icons/views/";
|
||||
private static final int SIZE = 16;
|
||||
private static final Map<MapView, Icon> LOADED = new EnumMap<>(MapView.class);
|
||||
|
||||
private MapViewIcon() {
|
||||
}
|
||||
|
||||
static Icon of(MapView view) {
|
||||
return LOADED.computeIfAbsent(view, shown -> load(shown, SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* The view's icon drawn larger, for an empty view.
|
||||
*/
|
||||
static Icon large(MapView view, int size) {
|
||||
return load(view, size);
|
||||
}
|
||||
|
||||
private static Icon load(MapView view, int size) {
|
||||
String fileName = view.name().toLowerCase(Locale.ROOT).replace('_', '-') + ".svg";
|
||||
return new FlatSVGIcon(ICON_FOLDER + fileName, size, size, MapViewIcon.class.getClassLoader());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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 javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.UIManager;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The map window's list of views, grouped under headers, each row with a picture of the view and the key that opens
|
||||
* it.
|
||||
*/
|
||||
final class MapViewList extends JList<MapViewList.Row> {
|
||||
private static final long serialVersionUID = -4130872409914437415L;
|
||||
private static final int ROW_HEIGHT = 28;
|
||||
private static final int NO_ROW = -1;
|
||||
|
||||
private final DefaultListModel<Row> rows = new DefaultListModel<>();
|
||||
private int lastViewRow;
|
||||
private int hoveredRow = NO_ROW;
|
||||
|
||||
MapViewList(Consumer<MapView> onViewChosen) {
|
||||
fillRows();
|
||||
setModel(rows);
|
||||
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
setCellRenderer(new RowRenderer());
|
||||
setBorder(StudioTheme.emptyBorder(4, 6, 4, 6));
|
||||
addListSelectionListener(event -> {
|
||||
if (event.getValueIsAdjusting() || skipHeaderRow()) {
|
||||
return;
|
||||
}
|
||||
Row selected = getSelectedValue();
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
lastViewRow = getSelectedIndex();
|
||||
onViewChosen.accept(selected.view());
|
||||
});
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
hover(viewRowAt(event));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
hover(NO_ROW);
|
||||
}
|
||||
};
|
||||
addMouseListener(hover);
|
||||
addMouseMotionListener(hover);
|
||||
}
|
||||
|
||||
private int viewRowAt(MouseEvent event) {
|
||||
int row = locationToIndex(event.getPoint());
|
||||
if (row < 0 || rows.get(row).isHeader() || !getCellBounds(row, row).contains(event.getPoint())) {
|
||||
return NO_ROW;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private void hover(int row) {
|
||||
if (row == hoveredRow) {
|
||||
return;
|
||||
}
|
||||
repaintRow(hoveredRow);
|
||||
hoveredRow = row;
|
||||
repaintRow(hoveredRow);
|
||||
}
|
||||
|
||||
private void repaintRow(int row) {
|
||||
Rectangle bounds = row == NO_ROW ? null : getCellBounds(row, row);
|
||||
if (bounds != null) {
|
||||
repaint(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
void select(MapView view) {
|
||||
for (int row = 0; row < rows.size(); row++) {
|
||||
if (rows.get(row).view() == view) {
|
||||
lastViewRow = row;
|
||||
setSelectedIndex(row);
|
||||
ensureIndexIsVisible(row);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fillRows() {
|
||||
MapView.Group currentGroup = null;
|
||||
for (MapView view : MapView.values()) {
|
||||
if (view.group() != currentGroup) {
|
||||
currentGroup = view.group();
|
||||
rows.addElement(Row.header(currentGroup));
|
||||
}
|
||||
rows.addElement(Row.of(view));
|
||||
}
|
||||
}
|
||||
|
||||
// Headers can't be chosen: clicking one keeps the current view, and the arrow keys carry on past it.
|
||||
private boolean skipHeaderRow() {
|
||||
int row = getSelectedIndex();
|
||||
if (row < 0 || !rows.get(row).isHeader()) {
|
||||
return false;
|
||||
}
|
||||
int step = row >= lastViewRow ? 1 : -1;
|
||||
int next = nextViewRow(row, step);
|
||||
if (next < 0) {
|
||||
next = nextViewRow(row, -step);
|
||||
}
|
||||
setSelectedIndex(next < 0 ? lastViewRow : next);
|
||||
ensureIndexIsVisible(getSelectedIndex());
|
||||
return true;
|
||||
}
|
||||
|
||||
private int nextViewRow(int from, int step) {
|
||||
for (int row = from + step; row >= 0 && row < rows.size(); row += step) {
|
||||
if (!rows.get(row).isHeader()) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
record Row(MapView.Group group, MapView view) {
|
||||
static Row header(MapView.Group group) {
|
||||
return new Row(group, null);
|
||||
}
|
||||
|
||||
static Row of(MapView view) {
|
||||
return new Row(view.group(), view);
|
||||
}
|
||||
|
||||
boolean isHeader() {
|
||||
return view == null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RowRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 6302156810211578474L;
|
||||
private static final int ICON_TEXT_GAP = 8;
|
||||
private static final int KEYCAP_SIZE = 17;
|
||||
private static final int KEYCAP_ARC = 5;
|
||||
private String shortcut;
|
||||
private boolean hovered;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
|
||||
Row row = (Row) value;
|
||||
super.getListCellRendererComponent(list, row.isHeader() ? "" : row.view().label(), index, selected && !row.isHeader(), false);
|
||||
if (row.isHeader()) {
|
||||
setText(row.group().label().toUpperCase(Locale.ROOT));
|
||||
setFont(StudioTheme.sectionFont());
|
||||
setForeground(StudioTheme.MUTED_TEXT);
|
||||
setBorder(StudioTheme.emptyBorder(index == 0 ? 2 : 10, 8, 0, 6));
|
||||
setIcon(null);
|
||||
setToolTipText(null);
|
||||
shortcut = null;
|
||||
hovered = false;
|
||||
setOpaque(true);
|
||||
return this;
|
||||
}
|
||||
setFont(StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, 8, 0, 8));
|
||||
setIcon(MapViewIcon.of(row.view()));
|
||||
setIconTextGap(StudioTheme.scale(ICON_TEXT_GAP));
|
||||
setToolTipText(row.view().description());
|
||||
shortcut = row.view().shortcutText();
|
||||
hovered = !selected && list instanceof MapViewList viewList && viewList.hoveredRow == index;
|
||||
setOpaque(!hovered);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
if (hovered) {
|
||||
paintHoverBackground(canvas);
|
||||
}
|
||||
super.paintComponent(graphics);
|
||||
if (shortcut != null) {
|
||||
paintKeycap(canvas);
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Shaped like the selection the look and feel paints, so moving between them doesn't jump.
|
||||
private void paintHoverBackground(Graphics2D canvas) {
|
||||
Insets inset = UIManager.getInsets("List.selectionInsets");
|
||||
int left = inset == null ? 0 : StudioTheme.scale(inset.left);
|
||||
int right = inset == null ? 0 : StudioTheme.scale(inset.right);
|
||||
int arc = StudioTheme.scale(UIManager.getInt("List.selectionArc"));
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(left, 0, getWidth() - left - right, getHeight(), arc, arc);
|
||||
}
|
||||
|
||||
private void paintKeycap(Graphics2D canvas) {
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
|
||||
int height = StudioTheme.scale(KEYCAP_SIZE);
|
||||
int width = Math.max(height, textWidth + StudioTheme.scale(8));
|
||||
int x = getWidth() - getInsets().right - width;
|
||||
int y = (getHeight() - height) / 2;
|
||||
int arc = StudioTheme.scale(KEYCAP_ARC);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(x, y, width - 1, height - 1, arc, arc);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int baseline = y + (height + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(shortcut, x + (width - textWidth) / 2, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
/*
|
||||
* 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.components.MapStyle;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRootPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.text.JTextComponent;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* The studio map: one window showing the world as tiles in several views, with the palette, object and noise explorers
|
||||
* as its Palettes, Objects and Noise views.
|
||||
*/
|
||||
public final class MapWindow implements MapCanvas.Listener {
|
||||
private static final String MAP_CARD = "map";
|
||||
private static final String PALETTES_CARD = "palettes";
|
||||
private static final String NOISE_CARD = "noise";
|
||||
private static final int SIDEBAR_WIDTH = 270;
|
||||
private static final int SIDEBAR_MINIMUM_WIDTH = 200;
|
||||
private static final int CONTENT_MINIMUM_WIDTH = 320;
|
||||
private static final double PALETTE_RECENTER_DISTANCE_BLOCKS = 1D;
|
||||
private static final int VIEW_LIST_SCROLL_INSETS = 4;
|
||||
private static final int EXPLORER_DIVIDER_HEIGHT = 7;
|
||||
|
||||
private final JFrame frame;
|
||||
private final PaletteExplorerStart start;
|
||||
private final MapCanvas canvas;
|
||||
private final MapViewList viewList;
|
||||
private final JLabel viewTitle = new JLabel();
|
||||
private final JPanel content = new JPanel(new CardLayout());
|
||||
private final JPanel statusBars = new JPanel(new CardLayout());
|
||||
private final JPanel sidebar = new JPanel(new BorderLayout());
|
||||
private final IslandPanel sidebarIsland = new IslandPanel(sidebar);
|
||||
private final JPanel inspectorSlot = new JPanel(new BorderLayout());
|
||||
private final JComponent viewListScroll;
|
||||
private final JSplitPane explorerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
|
||||
private final JSplitPane pickInspectorSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
|
||||
private final List<JComponent> mapControls = new ArrayList<>();
|
||||
private final List<JComponent> explorerControls = new ArrayList<>();
|
||||
private final List<JComponent> noiseControls = new ArrayList<>();
|
||||
private final JToolBar toolbar = StudioToolbar.create();
|
||||
private final JComponent resetViewButton = StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)",
|
||||
this::resetExplorerView);
|
||||
private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)");
|
||||
private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)");
|
||||
private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
|
||||
private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)");
|
||||
private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)");
|
||||
private final MapLegendPanel legendPanel;
|
||||
private final RegionExplorer regionExplorer;
|
||||
private final BiomeExplorer biomeExplorer;
|
||||
|
||||
private NoiseExplorerGUI paletteExplorer;
|
||||
private final Map<MapView, ObjectExplorer> objectExplorers = new EnumMap<>(MapView.class);
|
||||
private NoiseExplorerGUI noiseExplorer;
|
||||
private JComponent noiseSidebar;
|
||||
private MapView currentView;
|
||||
private double paletteCenterX;
|
||||
private double paletteCenterZ;
|
||||
|
||||
private MapWindow(PaletteExplorerStart start) {
|
||||
this.start = start;
|
||||
this.frame = GuiHost.createFrame("Iris Map");
|
||||
this.canvas = new MapCanvas(start.engine(), start.worldId(), start.playerId(), this);
|
||||
this.viewList = new MapViewList(this::showView);
|
||||
this.viewListScroll = StudioToolbar.scroll(viewList);
|
||||
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
|
||||
this.regionExplorer = new RegionExplorer(canvas::engine, canvas::previewRegion, canvas::showPicksAs);
|
||||
this.biomeExplorer = new BiomeExplorer(canvas::engine, canvas::showPicksAs);
|
||||
canvas.centerOn(start.centerX(), start.centerZ());
|
||||
buildFrame();
|
||||
}
|
||||
|
||||
public static void openMap(PaletteExplorerStart start) {
|
||||
EventQueue.invokeLater(() -> open(start, MapView.BIOMES));
|
||||
}
|
||||
|
||||
public static void openPalettes(PaletteExplorerStart start) {
|
||||
EventQueue.invokeLater(() -> open(start, MapView.PALETTES));
|
||||
}
|
||||
|
||||
public static void openNoise(PaletteExplorerStart start) {
|
||||
EventQueue.invokeLater(() -> open(start, MapView.NOISE));
|
||||
}
|
||||
|
||||
private static void open(PaletteExplorerStart start, MapView initialView) {
|
||||
StudioTheme.install();
|
||||
MapWindow window = new MapWindow(start);
|
||||
window.frame.setVisible(true);
|
||||
window.canvas.start();
|
||||
window.showView(initialView);
|
||||
}
|
||||
|
||||
private void buildFrame() {
|
||||
styleExplorerSplit();
|
||||
stylePickInspectorSplit();
|
||||
content.add(canvas, MAP_CARD);
|
||||
statusBars.add(canvas.statusBar(), MAP_CARD);
|
||||
sidebar.add(viewListScroll, BorderLayout.CENTER);
|
||||
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.add(buildToolbar(), BorderLayout.NORTH);
|
||||
frame.add(new IslandOverlay(sidebarIsland, content, StudioTheme.scale(SIDEBAR_WIDTH),
|
||||
StudioTheme.scale(SIDEBAR_MINIMUM_WIDTH), StudioTheme.scale(CONTENT_MINIMUM_WIDTH)), BorderLayout.CENTER);
|
||||
frame.add(inspectorSlot, BorderLayout.EAST);
|
||||
frame.add(statusBars, BorderLayout.SOUTH);
|
||||
installShortcuts(frame.getRootPane());
|
||||
keepClearOfIsland(canvas::setCoveredLeftWidth);
|
||||
frame.setSize(GuiHost.fitToScreen(StudioTheme.scaledSize(1440, 880)));
|
||||
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
viewTitle.setFont(StudioTheme.titleFont());
|
||||
viewTitle.setBorder(StudioTheme.emptyBorder(0, 6, 0, 14));
|
||||
toolbar.add(viewTitle);
|
||||
|
||||
mapControls.add(StudioToolbar.button("Refresh", "Redraw the map with the latest pack files (R)", canvas::refreshContent));
|
||||
mapControls.add(StudioToolbar.button("Reset zoom", "Go back to the default zoom (\\)", canvas::resetZoom));
|
||||
mapControls.add(gridToggle);
|
||||
mapControls.add(entitiesToggle);
|
||||
mapControls.add(followToggle);
|
||||
mapControls.add(toolbarSeparator());
|
||||
mapControls.add(shadingToggle);
|
||||
mapControls.add(bordersToggle);
|
||||
gridToggle.addActionListener(event -> canvas.toggleGrid());
|
||||
entitiesToggle.addActionListener(event -> canvas.toggleEntities());
|
||||
followToggle.addActionListener(event -> canvas.toggleFollow());
|
||||
shadingToggle.addActionListener(event -> canvas.toggleShading());
|
||||
bordersToggle.addActionListener(event -> canvas.toggleRegionBorders());
|
||||
mapControls.forEach(toolbar::add);
|
||||
|
||||
explorerControls.add(resetViewButton);
|
||||
explorerControls.forEach(toolbar::add);
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue());
|
||||
toolbar.add(StudioToolbar.themeButton());
|
||||
JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp);
|
||||
mapControls.add(help);
|
||||
toolbar.add(help);
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
private static JComponent toolbarSeparator() {
|
||||
JToolBar.Separator separator = new JToolBar.Separator();
|
||||
separator.setOrientation(SwingConstants.VERTICAL);
|
||||
return separator;
|
||||
}
|
||||
|
||||
private void showView(MapView view) {
|
||||
if (view == null || view == currentView) {
|
||||
return;
|
||||
}
|
||||
MapView previousView = currentView;
|
||||
currentView = view;
|
||||
viewList.select(view);
|
||||
viewTitle.setText(view.label());
|
||||
viewTitle.setToolTipText(view.description());
|
||||
if (view.isTileMap()) {
|
||||
showMap(previousView, view);
|
||||
} else if (view == MapView.PALETTES) {
|
||||
showPalettes(previousView);
|
||||
} else if (view == MapView.OBJECTS || view == MapView.PROCEDURAL) {
|
||||
showObjectExplorer(view);
|
||||
} else {
|
||||
showNoise();
|
||||
}
|
||||
frame.getRootPane().revalidate();
|
||||
frame.getRootPane().repaint();
|
||||
}
|
||||
|
||||
private void showMap(MapView previousView, MapView view) {
|
||||
if (previousView == MapView.PALETTES && paletteExplorer != null) {
|
||||
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
|
||||
}
|
||||
canvas.showView(view);
|
||||
canvas.setActive(true);
|
||||
showControls(mapControls);
|
||||
inspectorSlot.removeAll();
|
||||
if (view == MapView.REGIONS) {
|
||||
showRegionExplorer(previousView);
|
||||
} else if (view == MapView.BIOMES) {
|
||||
showBiomeExplorer();
|
||||
} else {
|
||||
useSidebar(viewListScroll);
|
||||
inspectorSlot.add(legendPanel, BorderLayout.CENTER);
|
||||
}
|
||||
showCard(MAP_CARD);
|
||||
canvas.requestFocusInWindow();
|
||||
}
|
||||
|
||||
// The region inspector sits above the legend, which then lists the chosen region's biomes on the map.
|
||||
private void showRegionExplorer(MapView previousView) {
|
||||
if (previousView != MapView.REGIONS) {
|
||||
regionExplorer.loadRegions();
|
||||
}
|
||||
useSidebar(viewListAbove(regionExplorer.navigator()));
|
||||
showPickInspector(regionExplorer.inspector());
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
regionExplorer.measure(canvas.viewArea());
|
||||
}
|
||||
|
||||
private void showBiomeExplorer() {
|
||||
useSidebar(viewListScroll);
|
||||
showPickInspector(biomeExplorer.inspector());
|
||||
biomeExplorer.measure(canvas.viewArea());
|
||||
}
|
||||
|
||||
// The region and biome views share how the map draws its picks, so their switches show the same choice.
|
||||
private void showPickInspector(PickInspectorPanel inspector) {
|
||||
inspector.selectDisplay(canvas.pickDisplay());
|
||||
pickInspectorSplit.setTopComponent(inspector);
|
||||
pickInspectorSplit.setBottomComponent(legendPanel);
|
||||
inspectorSlot.add(pickInspectorSplit, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void stylePickInspectorSplit() {
|
||||
pickInspectorSplit.setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
pickInspectorSplit.setResizeWeight(0.7D);
|
||||
pickInspectorSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
pickInspectorSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
|
||||
pickInspectorSplit.putClientProperty("FlatLaf.style", "style: grip");
|
||||
}
|
||||
|
||||
private void showPalettes(MapView previousView) {
|
||||
boolean firstOpen = paletteExplorer == null;
|
||||
if (firstOpen) {
|
||||
createPaletteExplorer(previousView == null);
|
||||
} else {
|
||||
followMapIntoPalettes();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(explorerControls);
|
||||
useSidebar(viewListAbove(paletteExplorer.paletteSidebar()));
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(paletteExplorer.paletteInspector(), BorderLayout.CENTER);
|
||||
showCard(PALETTES_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
paletteExplorer.openViewer();
|
||||
}
|
||||
}
|
||||
|
||||
private void showObjectExplorer(MapView view) {
|
||||
boolean firstOpen = !objectExplorers.containsKey(view);
|
||||
ObjectExplorer explorer = objectExplorers.computeIfAbsent(view, this::createObjectExplorer);
|
||||
canvas.setActive(false);
|
||||
showControls(explorerControls);
|
||||
useSidebar(viewListAbove(explorer.browser()));
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(explorer.overview(), BorderLayout.CENTER);
|
||||
showCard(view.name());
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
explorer.open();
|
||||
}
|
||||
}
|
||||
|
||||
private void keepClearOfIsland(IntConsumer setCoveredLeftWidth) {
|
||||
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
|
||||
sidebarIsland.addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent event) {
|
||||
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ObjectExplorer createObjectExplorer(MapView view) {
|
||||
ObjectSource source = view == MapView.PROCEDURAL ? new ProceduralObjectSource() : new PackObjectSource();
|
||||
ObjectExplorer explorer = new ObjectExplorer(start.engine(), start.worldId(), source);
|
||||
keepClearOfIsland(explorer::setCoveredLeftWidth);
|
||||
content.add(explorer.view(), view.name());
|
||||
statusBars.add(explorer.statusBar(), view.name());
|
||||
return explorer;
|
||||
}
|
||||
|
||||
private void showNoise() {
|
||||
boolean firstOpen = noiseExplorer == null;
|
||||
if (firstOpen) {
|
||||
createNoiseExplorer();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(noiseControls, explorerControls);
|
||||
useSidebar(viewListAbove(noiseSidebar));
|
||||
inspectorSlot.removeAll();
|
||||
showCard(NOISE_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
noiseExplorer.openViewer();
|
||||
}
|
||||
}
|
||||
|
||||
// Both explorers share one split, so the divider stays where it was dragged when switching between them.
|
||||
private JSplitPane viewListAbove(JComponent explorerSidebar) {
|
||||
boolean firstUse = explorerSplit.getBottomComponent() == null;
|
||||
explorerSplit.setTopComponent(viewListScroll);
|
||||
explorerSplit.setBottomComponent(explorerSidebar);
|
||||
if (firstUse) {
|
||||
EventQueue.invokeLater(this::fitViewListAboveExplorer);
|
||||
}
|
||||
return explorerSplit;
|
||||
}
|
||||
|
||||
private void styleExplorerSplit() {
|
||||
explorerSplit.setBorder(BorderFactory.createEmptyBorder());
|
||||
explorerSplit.setResizeWeight(0D);
|
||||
explorerSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
explorerSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
|
||||
explorerSplit.putClientProperty("FlatLaf.style", "style: grip");
|
||||
}
|
||||
|
||||
// Every view stays in sight, so the divider starts below the last one and can only be dragged further down. Measured
|
||||
// once the sidebar is laid out, since the list's rows are taller before they know their width.
|
||||
private void fitViewListAboveExplorer() {
|
||||
int fullHeight = viewList.getPreferredSize().height + VIEW_LIST_SCROLL_INSETS;
|
||||
viewListScroll.setMinimumSize(new Dimension(0, fullHeight));
|
||||
explorerSplit.setDividerLocation(fullHeight);
|
||||
}
|
||||
|
||||
private void createNoiseExplorer() {
|
||||
noiseExplorer = NoiseExplorerGUI.createNoiseExplorer(start.engine());
|
||||
noiseSidebar = noiseExplorer.noiseSidebar();
|
||||
noiseControls.addAll(noiseExplorer.noiseControls());
|
||||
noiseControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
|
||||
content.add(noiseExplorer, NOISE_CARD);
|
||||
statusBars.add(noiseExplorer.statusBar(), NOISE_CARD);
|
||||
}
|
||||
|
||||
private void createPaletteExplorer(boolean openedOnPalettes) {
|
||||
PaletteExplorerStart explorerStart = openedOnPalettes
|
||||
? start
|
||||
: new PaletteExplorerStart(start.engine(), start.worldId(), start.playerId(), null, canvas.centerX(), canvas.centerZ());
|
||||
paletteExplorer = NoiseExplorerGUI.createPaletteExplorer(explorerStart);
|
||||
content.add(paletteExplorer, PALETTES_CARD);
|
||||
statusBars.add(paletteExplorer.statusBar(), PALETTES_CARD);
|
||||
rememberPaletteCenter(explorerStart.centerX(), explorerStart.centerZ());
|
||||
if (!openedOnPalettes) {
|
||||
showSurfaceBiomePalettes(explorerStart.centerX(), explorerStart.centerZ());
|
||||
}
|
||||
}
|
||||
|
||||
// Coming back from the map only moves the palettes when the map was moved, so a chosen layer survives a quick look.
|
||||
private void followMapIntoPalettes() {
|
||||
double movedX = canvas.centerX() - paletteCenterX;
|
||||
double movedZ = canvas.centerZ() - paletteCenterZ;
|
||||
if (Math.hypot(movedX, movedZ) < PALETTE_RECENTER_DISTANCE_BLOCKS) {
|
||||
return;
|
||||
}
|
||||
paletteExplorer.centerOn(canvas.centerX(), canvas.centerZ());
|
||||
rememberPaletteCenter(canvas.centerX(), canvas.centerZ());
|
||||
showSurfaceBiomePalettes(canvas.centerX(), canvas.centerZ());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowPalettesAt(double worldX, double worldZ) {
|
||||
canvas.centerOn(worldX, worldZ);
|
||||
showView(MapView.PALETTES);
|
||||
}
|
||||
|
||||
private void showSurfaceBiomePalettes(double worldX, double worldZ) {
|
||||
canvas.findSurfaceBiome(worldX, worldZ, biomeKey -> {
|
||||
if (paletteExplorer != null) {
|
||||
paletteExplorer.showPalettesAt(worldX, worldZ, biomeKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void rememberPaletteCenter(double worldX, double worldZ) {
|
||||
paletteCenterX = worldX;
|
||||
paletteCenterZ = worldZ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTogglesChanged() {
|
||||
gridToggle.setSelected(canvas.isGridVisible());
|
||||
entitiesToggle.setSelected(canvas.areEntitiesVisible());
|
||||
followToggle.setSelected(canvas.isFollowing());
|
||||
shadingToggle.setSelected(canvas.style().shading());
|
||||
bordersToggle.setSelected(canvas.style().regionBorders());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLegendChanged(String title, List<MapCensus.Share> shares, MapStyle.Highlight highlight) {
|
||||
legendPanel.show(title, shares, highlight);
|
||||
if (currentView == MapView.REGIONS) {
|
||||
regionExplorer.measure(canvas.viewArea());
|
||||
} else if (currentView == MapView.BIOMES) {
|
||||
biomeExplorer.measure(canvas.viewArea());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContentRefreshed() {
|
||||
regionExplorer.refresh();
|
||||
biomeExplorer.refresh();
|
||||
}
|
||||
|
||||
private void resetExplorerView() {
|
||||
ObjectExplorer objectExplorer = objectExplorers.get(currentView);
|
||||
if (objectExplorer != null) {
|
||||
objectExplorer.resetCamera();
|
||||
return;
|
||||
}
|
||||
NoiseExplorerGUI explorer = currentView == MapView.NOISE ? noiseExplorer : paletteExplorer;
|
||||
if (explorer != null) {
|
||||
explorer.resetViewport();
|
||||
}
|
||||
}
|
||||
|
||||
private void useSidebar(JComponent component) {
|
||||
sidebar.removeAll();
|
||||
sidebar.add(component, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void showCard(String card) {
|
||||
((CardLayout) content.getLayout()).show(content, card);
|
||||
((CardLayout) statusBars.getLayout()).show(statusBars, card);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private void showControls(List<JComponent>... visibleControls) {
|
||||
for (List<JComponent> controls : List.of(mapControls, explorerControls, noiseControls)) {
|
||||
controls.forEach(control -> control.setVisible(false));
|
||||
}
|
||||
for (List<JComponent> controls : visibleControls) {
|
||||
controls.forEach(control -> control.setVisible(true));
|
||||
}
|
||||
}
|
||||
|
||||
private void installShortcuts(JRootPane rootPane) {
|
||||
for (MapView view : MapView.values()) {
|
||||
bind(rootPane, KeyStroke.getKeyStroke(view.shortcutKey(), 0), "view-" + view.name(), () -> showView(view));
|
||||
}
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "next-view", this::showNextView);
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "refresh", this::refreshOrReset);
|
||||
bindMap(rootPane, KeyEvent.VK_F, 0, "follow", canvas::toggleFollow);
|
||||
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
|
||||
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
|
||||
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
|
||||
bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor);
|
||||
bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis);
|
||||
bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading);
|
||||
bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders);
|
||||
bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_MINUS, 0, "zoom-out", canvas::zoomOut);
|
||||
bindMap(rootPane, KeyEvent.VK_SUBTRACT, 0, "zoom-out-keypad", canvas::zoomOut);
|
||||
bindMap(rootPane, KeyEvent.VK_BACK_SLASH, 0, "reset-zoom", canvas::resetZoom);
|
||||
bindMap(rootPane, KeyEvent.VK_SLASH, 0, "help", canvas::toggleHelp);
|
||||
bindMap(rootPane, KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK, "help-question", canvas::toggleHelp);
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, KeyEvent.SHIFT_DOWN_MASK, false), "details-on",
|
||||
() -> canvas.setDetailedHover(true));
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, 0, true), "details-off", () -> canvas.setDetailedHover(false));
|
||||
}
|
||||
|
||||
private void bindMap(JRootPane rootPane, int keyCode, int modifiers, String name, Runnable action) {
|
||||
bind(rootPane, KeyStroke.getKeyStroke(keyCode, modifiers), name, () -> {
|
||||
if (currentView != null && currentView.isTileMap()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keys typed into a search box belong to that box, not to the window's shortcuts.
|
||||
private static void bind(JRootPane rootPane, KeyStroke keyStroke, String name, Runnable action) {
|
||||
rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, name);
|
||||
rootPane.getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showNextView() {
|
||||
MapView[] views = MapView.values();
|
||||
showView(views[(currentView.ordinal() + 1) % views.length]);
|
||||
}
|
||||
|
||||
private void refreshOrReset() {
|
||||
if (currentView.isTileMap()) {
|
||||
canvas.refreshContent();
|
||||
} else {
|
||||
resetExplorerView();
|
||||
}
|
||||
}
|
||||
|
||||
private void close() {
|
||||
canvas.close();
|
||||
regionExplorer.close();
|
||||
biomeExplorer.close();
|
||||
if (paletteExplorer != null) {
|
||||
paletteExplorer.close();
|
||||
}
|
||||
objectExplorers.values().forEach(ObjectExplorer::close);
|
||||
if (noiseExplorer != null) {
|
||||
noiseExplorer.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.volmit.iris.engine.object.IrisBiomeCustom;
|
||||
import org.bukkit.NamespacedKey;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
// Works out biome tint colours the way Minecraft does, from the colour maps and vanilla biome files in the client jar.
|
||||
final class MinecraftBiomeColors {
|
||||
private static final String COLOR_MAP_FOLDER = "assets/minecraft/textures/colormap/";
|
||||
private static final int COLOR_MAP_SIZE = 256;
|
||||
private static final int GRASS_FALLBACK = 0xFF00FF;
|
||||
private static final int FOLIAGE_FALLBACK = 0x48B518;
|
||||
private static final int DRY_FOLIAGE_FALLBACK = 0x5C3C32;
|
||||
private static final float UNKNOWN_BIOME_TEMPERATURE = 0.5F;
|
||||
private static final float UNKNOWN_BIOME_DOWNFALL = 0.5F;
|
||||
private static final int UNKNOWN_BIOME_WATER = 0x3F76E4;
|
||||
private static final int UNPARSEABLE_COLOR = 0x000000;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final ColorMap grassMap;
|
||||
private final ColorMap foliageMap;
|
||||
private final ColorMap dryFoliageMap;
|
||||
private final Map<NamespacedKey, BiomeTintColors> vanillaBiomes = new ConcurrentHashMap<>();
|
||||
|
||||
private MinecraftBiomeColors(ZipFile clientJar, ColorMap grassMap, ColorMap foliageMap, ColorMap dryFoliageMap) {
|
||||
this.clientJar = clientJar;
|
||||
this.grassMap = grassMap;
|
||||
this.foliageMap = foliageMap;
|
||||
this.dryFoliageMap = dryFoliageMap;
|
||||
}
|
||||
|
||||
static MinecraftBiomeColors load(ZipFile clientJar) throws IOException {
|
||||
return new MinecraftBiomeColors(
|
||||
clientJar,
|
||||
ColorMap.read(clientJar, "grass.png", GRASS_FALLBACK),
|
||||
ColorMap.read(clientJar, "foliage.png", FOLIAGE_FALLBACK),
|
||||
ColorMap.read(clientJar, "dry_foliage.png", DRY_FOLIAGE_FALLBACK)
|
||||
);
|
||||
}
|
||||
|
||||
BiomeTintColors vanilla(NamespacedKey biome) {
|
||||
return vanillaBiomes.computeIfAbsent(biome, this::readVanillaBiome);
|
||||
}
|
||||
|
||||
// Iris registers custom biomes as data pack biomes built from these fields, see IrisBiomeCustom#generateJson.
|
||||
BiomeTintColors custom(IrisBiomeCustom biome) {
|
||||
float temperature = (float) biome.getTemperature();
|
||||
float downfall = (float) biome.getHumidity();
|
||||
return new BiomeTintColors(
|
||||
biome.getGrassColor().isEmpty() ? grassMap.colorAt(temperature, downfall) : parseIrisColor(biome.getGrassColor()),
|
||||
BiomeTintColors.GrassColorModifier.NONE,
|
||||
biome.getFoliageColor().isEmpty() ? foliageMap.colorAt(temperature, downfall) : parseIrisColor(biome.getFoliageColor()),
|
||||
dryFoliageMap.colorAt(temperature, downfall),
|
||||
parseIrisColor(biome.getWaterColor())
|
||||
);
|
||||
}
|
||||
|
||||
private BiomeTintColors readVanillaBiome(NamespacedKey biome) {
|
||||
JsonObject definition = readBiomeDefinition(biome);
|
||||
if (definition == null) {
|
||||
return fromClimate(UNKNOWN_BIOME_TEMPERATURE, UNKNOWN_BIOME_DOWNFALL, new JsonObject());
|
||||
}
|
||||
JsonObject effects = definition.has("effects") ? definition.getAsJsonObject("effects") : new JsonObject();
|
||||
return fromClimate(definition.get("temperature").getAsFloat(), definition.get("downfall").getAsFloat(), effects);
|
||||
}
|
||||
|
||||
private BiomeTintColors fromClimate(float temperature, float downfall, JsonObject effects) {
|
||||
return new BiomeTintColors(
|
||||
effects.has("grass_color") ? parseVanillaColor(effects.get("grass_color")) : grassMap.colorAt(temperature, downfall),
|
||||
effects.has("grass_color_modifier")
|
||||
? BiomeTintColors.GrassColorModifier.named(effects.get("grass_color_modifier").getAsString())
|
||||
: BiomeTintColors.GrassColorModifier.NONE,
|
||||
effects.has("foliage_color") ? parseVanillaColor(effects.get("foliage_color")) : foliageMap.colorAt(temperature, downfall),
|
||||
effects.has("dry_foliage_color") ? parseVanillaColor(effects.get("dry_foliage_color")) : dryFoliageMap.colorAt(temperature, downfall),
|
||||
effects.has("water_color") ? parseVanillaColor(effects.get("water_color")) : UNKNOWN_BIOME_WATER
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject readBiomeDefinition(NamespacedKey biome) {
|
||||
try {
|
||||
return ClientJarFiles.readJson(clientJar, "data/" + biome.getNamespace() + "/worldgen/biome/" + biome.getKey() + ".json");
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Older versions store colours as plain numbers, newer ones as "#rrggbb" strings.
|
||||
private static int parseVanillaColor(JsonElement color) {
|
||||
if (color.getAsJsonPrimitive().isNumber()) {
|
||||
return color.getAsInt() & 0xFFFFFF;
|
||||
}
|
||||
return Integer.parseInt(color.getAsString().substring(1), 16);
|
||||
}
|
||||
|
||||
private static int parseIrisColor(String color) {
|
||||
try {
|
||||
return Color.decode((color.startsWith("#") ? color : "#" + color).trim()).getRGB() & 0xFFFFFF;
|
||||
} catch (RuntimeException error) {
|
||||
return UNPARSEABLE_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
// Minecraft's ColorMapColorUtil: temperature picks the column and temperature times downfall picks the row.
|
||||
private record ColorMap(int[] pixels, int fallback) {
|
||||
private static ColorMap read(ZipFile clientJar, String name, int fallback) throws IOException {
|
||||
BufferedImage image = ClientJarFiles.readImage(clientJar, COLOR_MAP_FOLDER + name);
|
||||
if (image == null) {
|
||||
throw new IOException("Minecraft colour map " + name + " is missing");
|
||||
}
|
||||
int[] pixels = new int[COLOR_MAP_SIZE * COLOR_MAP_SIZE];
|
||||
for (int y = 0; y < COLOR_MAP_SIZE; y++) {
|
||||
for (int x = 0; x < COLOR_MAP_SIZE; x++) {
|
||||
pixels[y * COLOR_MAP_SIZE + x] = image.getRGB(x, y) & 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
return new ColorMap(pixels, fallback);
|
||||
}
|
||||
|
||||
private int colorAt(float temperature, float downfall) {
|
||||
double clampedTemperature = Math.clamp(temperature, 0F, 1F);
|
||||
double clampedDownfall = Math.clamp(downfall, 0F, 1F) * clampedTemperature;
|
||||
int x = (int) ((1D - clampedTemperature) * 255D);
|
||||
int y = (int) ((1D - clampedDownfall) * 255D);
|
||||
int index = y << 8 | x;
|
||||
return index >= pixels.length ? fallback : pixels[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
// Mirrors Minecraft's BlockColors: which colour each tinted face of a block is multiplied with in the world.
|
||||
final class MinecraftBlockTints {
|
||||
static final int NO_TINT = 0xFFFFFF;
|
||||
private static final Set<String> GRASS_TINTED = Set.of(
|
||||
"grass_block", "short_grass", "fern", "potted_fern", "bush", "tall_grass", "large_fern", "sugar_cane");
|
||||
private static final Set<String> GRASS_TINTED_ON_SECOND_LAYER = Set.of("pink_petals", "wildflowers");
|
||||
private static final Set<String> FOLIAGE_TINTED = Set.of(
|
||||
"oak_leaves", "jungle_leaves", "acacia_leaves", "dark_oak_leaves", "vine", "mangrove_leaves");
|
||||
private static final Set<String> WATER_TINTED_BY_FLUID_RENDERER = Set.of("water", "bubble_column");
|
||||
private static final Set<String> GROWING_STEMS = Set.of("melon_stem", "pumpkin_stem");
|
||||
private static final Map<String, Integer> FIXED_TINTS = Map.of(
|
||||
"spruce_leaves", 0x619961,
|
||||
"birch_leaves", 0x80A755,
|
||||
"attached_melon_stem", 0xE0C71C,
|
||||
"attached_pumpkin_stem", 0xE0C71C,
|
||||
"lily_pad", 0x208030
|
||||
);
|
||||
|
||||
private MinecraftBlockTints() {
|
||||
}
|
||||
|
||||
static Optional<BiomeTint> biomeTint(String blockName, int tintIndex) {
|
||||
if (WATER_TINTED_BY_FLUID_RENDERER.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.WATER);
|
||||
}
|
||||
if (GRASS_TINTED_ON_SECOND_LAYER.contains(blockName)) {
|
||||
return tintIndex == 1 ? Optional.of(BiomeTint.GRASS) : Optional.empty();
|
||||
}
|
||||
if (tintIndex != 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (GRASS_TINTED.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.GRASS);
|
||||
}
|
||||
if (FOLIAGE_TINTED.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.FOLIAGE);
|
||||
}
|
||||
if (blockName.equals("leaf_litter")) {
|
||||
return Optional.of(BiomeTint.DRY_FOLIAGE);
|
||||
}
|
||||
if (blockName.equals("water_cauldron")) {
|
||||
return Optional.of(BiomeTint.WATER);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static int fixedTint(String blockName, Map<String, String> properties, int tintIndex) {
|
||||
if (tintIndex != 0) {
|
||||
return NO_TINT;
|
||||
}
|
||||
if (GROWING_STEMS.contains(blockName)) {
|
||||
return stemTint(Integer.parseInt(properties.getOrDefault("age", "0")));
|
||||
}
|
||||
return FIXED_TINTS.getOrDefault(blockName, NO_TINT);
|
||||
}
|
||||
|
||||
private static int stemTint(int age) {
|
||||
return (age * 32) << 16 | (255 - age * 8) << 8 | (age * 4);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 java.awt.Color;
|
||||
|
||||
interface NoiseColorScale {
|
||||
int colorFinite(double value);
|
||||
|
||||
default int colorAt(double value, double worldX, double worldZ) {
|
||||
return colorFinite(value);
|
||||
}
|
||||
|
||||
int colorInvalid();
|
||||
|
||||
default int colorInvalidAt(double worldX, double worldZ) {
|
||||
return colorInvalid();
|
||||
}
|
||||
|
||||
Color displayColorNormalized(double normalized);
|
||||
|
||||
String label();
|
||||
|
||||
double minimum();
|
||||
|
||||
double maximum();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 java.awt.Color;
|
||||
|
||||
enum NoisePalette implements NoiseColorScale {
|
||||
TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}),
|
||||
SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}),
|
||||
GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF});
|
||||
|
||||
static final int INVALID_COLOR = 0xFF2DAA;
|
||||
|
||||
private final String label;
|
||||
private final double minimum;
|
||||
private final double maximum;
|
||||
private final int[] lookup;
|
||||
private final Color[] displayColors;
|
||||
|
||||
NoisePalette(String label, double minimum, double maximum, int[] stops) {
|
||||
this.label = label;
|
||||
this.minimum = minimum;
|
||||
this.maximum = maximum;
|
||||
this.lookup = buildLookup(stops);
|
||||
this.displayColors = buildDisplayColors(lookup);
|
||||
}
|
||||
|
||||
int color(double value) {
|
||||
if (!Double.isFinite(value)) {
|
||||
return INVALID_COLOR;
|
||||
}
|
||||
return colorFinite(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorFinite(double value) {
|
||||
double normalized = (value - minimum) / (maximum - minimum);
|
||||
return colorNormalized(normalized);
|
||||
}
|
||||
|
||||
int colorNormalized(double normalized) {
|
||||
double clipped = Math.max(0D, Math.min(1D, normalized));
|
||||
return lookup[(int) Math.round(clipped * (lookup.length - 1))];
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalid() {
|
||||
return INVALID_COLOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Color displayColorNormalized(double normalized) {
|
||||
double clipped = Math.max(0D, Math.min(1D, normalized));
|
||||
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double minimum() {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double maximum() {
|
||||
return maximum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
|
||||
private static int[] buildLookup(int[] stops) {
|
||||
int[] values = new int[256];
|
||||
for (int index = 0; index < values.length; index++) {
|
||||
double position = (index / 255D) * (stops.length - 1);
|
||||
int lowerIndex = Math.min(stops.length - 2, (int) position);
|
||||
double fraction = position - lowerIndex;
|
||||
values[index] = interpolate(stops[lowerIndex], stops[lowerIndex + 1], fraction);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Color[] buildDisplayColors(int[] lookup) {
|
||||
Color[] colors = new Color[lookup.length];
|
||||
for (int index = 0; index < lookup.length; index++) {
|
||||
colors[index] = new Color(lookup[index]);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private static int interpolate(int from, int to, double fraction) {
|
||||
int red = channel(from, 16, to, fraction);
|
||||
int green = channel(from, 8, to, fraction);
|
||||
int blue = channel(from, 0, to, fraction);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
private static int channel(int from, int shift, int to, double fraction) {
|
||||
int fromChannel = (from >>> shift) & 0xFF;
|
||||
int toChannel = (to >>> shift) & 0xFF;
|
||||
return (int) Math.round(fromChannel + ((toChannel - fromChannel) * fraction));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* 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 javax.swing.JComponent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* One noise that picks regions or biomes: the spread of its values in view above the bands that pick each option,
|
||||
* and a table of how much of the noise each option is given and how much it gets in view. A long table shows only
|
||||
* the options with the most ground in view until asked for all; a noise with one option to pick is a single line.
|
||||
*/
|
||||
final class NoisePickChart extends JComponent {
|
||||
private static final long serialVersionUID = 1873365510472853316L;
|
||||
private static final int PREFERRED_WIDTH = 300;
|
||||
private static final int MARGIN = 16;
|
||||
private static final int HISTOGRAM_HEIGHT = 64;
|
||||
private static final int BAND_HEIGHT = 12;
|
||||
private static final int TITLE_GAP = 6;
|
||||
private static final int SECTION_GAP = 12;
|
||||
private static final int BOTTOM_GAP = 14;
|
||||
private static final Color HISTOGRAM_BAR = new Color(0x9D, 0xA0, 0xA8, 150);
|
||||
private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70);
|
||||
private static final String WIDEST_SHARE = "▲ 100.0%";
|
||||
private static final int SHORT_TABLE_ROWS = 6;
|
||||
|
||||
private final NoisePickSpread spread;
|
||||
private final List<NoisePickSpread.Row> rows;
|
||||
private final String optionNoun;
|
||||
private final Consumer<Boolean> onShowAllRowsChanged;
|
||||
private boolean showsAllRows;
|
||||
private Rectangle showAllToggleBounds;
|
||||
|
||||
/**
|
||||
* @param optionNoun what the noise picks, as a table heading: "Regions" or "Biomes"
|
||||
* @param showsAllRows whether a long table starts with all its rows
|
||||
* @param onShowAllRowsChanged receives whether the table shows all its rows after the user asked for more or fewer
|
||||
*/
|
||||
NoisePickChart(NoisePickSpread spread, String optionNoun, boolean showsAllRows, Consumer<Boolean> onShowAllRowsChanged) {
|
||||
this.spread = spread;
|
||||
this.rows = spread.rows();
|
||||
this.optionNoun = optionNoun;
|
||||
this.showsAllRows = showsAllRows;
|
||||
this.onShowAllRowsChanged = onShowAllRowsChanged;
|
||||
setAlignmentX(LEFT_ALIGNMENT);
|
||||
listenForShowAllToggle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
if (hasSingleOption()) {
|
||||
return new Dimension(StudioTheme.scale(PREFERRED_WIDTH), StudioTheme.scale(SECTION_GAP) + tableHeight()
|
||||
+ StudioTheme.scale(BOTTOM_GAP));
|
||||
}
|
||||
int height = StudioTheme.scale(SECTION_GAP) + spreadHeight() + StudioTheme.scale(SECTION_GAP) + tableHeight()
|
||||
+ StudioTheme.scale(BOTTOM_GAP);
|
||||
return new Dimension(StudioTheme.scale(PREFERRED_WIDTH), height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMaximumSize() {
|
||||
return new Dimension(Integer.MAX_VALUE, getPreferredSize().height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
int margin = StudioTheme.scale(MARGIN);
|
||||
int width = getWidth() - margin * 2;
|
||||
int top = StudioTheme.scale(SECTION_GAP);
|
||||
if (hasSingleOption()) {
|
||||
drawSingleOption(canvas, margin, top, width);
|
||||
return;
|
||||
}
|
||||
drawSpread(canvas, margin, top, width);
|
||||
drawTable(canvas, margin, top + spreadHeight() + StudioTheme.scale(SECTION_GAP), width);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private int spreadHeight() {
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + StudioTheme.scale(HISTOGRAM_HEIGHT)
|
||||
+ StudioTheme.scale(2) + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + smallMetrics().getHeight();
|
||||
}
|
||||
|
||||
private boolean hasSingleOption() {
|
||||
return rows.size() == 1;
|
||||
}
|
||||
|
||||
private boolean isLongTable() {
|
||||
return rows.size() > SHORT_TABLE_ROWS;
|
||||
}
|
||||
|
||||
private int tableHeight() {
|
||||
int toggleRows = isLongTable() ? 1 : 0;
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + rowHeight() * (shownRows().size() + toggleRows);
|
||||
}
|
||||
|
||||
// A short table keeps the options with the most ground in view, still in the order the noise meets them.
|
||||
private List<NoisePickSpread.Row> shownRows() {
|
||||
if (showsAllRows || !isLongTable()) {
|
||||
return rows;
|
||||
}
|
||||
Set<NoisePickSpread.Row> largest = rows.stream()
|
||||
.sorted(Comparator.comparingDouble(NoisePickSpread.Row::actualShare).reversed())
|
||||
.limit(SHORT_TABLE_ROWS)
|
||||
.collect(Collectors.toSet());
|
||||
return rows.stream().filter(largest::contains).toList();
|
||||
}
|
||||
|
||||
private void listenForShowAllToggle() {
|
||||
MouseAdapter toggle = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
if (showAllToggleBounds == null || !showAllToggleBounds.contains(event.getPoint())) {
|
||||
return;
|
||||
}
|
||||
showsAllRows = !showsAllRows;
|
||||
onShowAllRowsChanged.accept(showsAllRows);
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
boolean overToggle = showAllToggleBounds != null && showAllToggleBounds.contains(event.getPoint());
|
||||
setCursor(overToggle ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
|
||||
}
|
||||
};
|
||||
addMouseListener(toggle);
|
||||
addMouseMotionListener(toggle);
|
||||
}
|
||||
|
||||
private void drawSingleOption(Graphics2D canvas, int x, int top, int width) {
|
||||
drawSectionTitle(canvas, spread.title(), x, top + sectionMetrics().getAscent());
|
||||
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
NoisePickSpread.Row row = rows.getFirst();
|
||||
String only = "picked everywhere";
|
||||
int nameRight = x + width - textMetrics().stringWidth(only) - StudioTheme.scale(10);
|
||||
int baseline = drawOptionName(canvas, row, x, rowTop, nameRight);
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
drawRightAligned(canvas, only, x + width, baseline);
|
||||
}
|
||||
|
||||
private void drawSpread(Graphics2D canvas, int x, int top, int width) {
|
||||
drawSectionTitle(canvas, spread.title(), x, top + sectionMetrics().getAscent());
|
||||
int histogramTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT);
|
||||
drawHistogram(canvas, x, histogramTop, width, histogramHeight);
|
||||
int bandTop = histogramTop + histogramHeight + StudioTheme.scale(2);
|
||||
drawBands(canvas, x, width, histogramTop, bandTop);
|
||||
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int labelY = bandTop + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + metrics.getAscent();
|
||||
canvas.drawString("0", x, labelY);
|
||||
canvas.drawString("0.5", x + (width - metrics.stringWidth("0.5")) / 2, labelY);
|
||||
canvas.drawString("1", x + width - metrics.stringWidth("1"), labelY);
|
||||
}
|
||||
|
||||
private void drawHistogram(Graphics2D canvas, int x, int top, int width, int height) {
|
||||
long[] histogram = spread.histogram();
|
||||
long peak = 1L;
|
||||
for (long count : histogram) {
|
||||
peak = Math.max(peak, count);
|
||||
}
|
||||
canvas.setColor(HISTOGRAM_BAR);
|
||||
for (int bin = 0; bin < histogram.length; bin++) {
|
||||
int barLeft = x + (bin * width) / histogram.length;
|
||||
int barRight = x + ((bin + 1) * width) / histogram.length;
|
||||
int barHeight = (int) Math.round(height * (histogram[bin] / (double) peak));
|
||||
canvas.fillRect(barLeft, top + height - barHeight, Math.max(1, barRight - barLeft - 1), barHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBands(Graphics2D canvas, int x, int width, int histogramTop, int bandTop) {
|
||||
int bandHeight = StudioTheme.scale(BAND_HEIGHT);
|
||||
for (NoisePickSpread.Band band : spread.bands()) {
|
||||
int bandLeft = x + (int) Math.round(band.fromNoise() * width);
|
||||
int bandRight = x + (int) Math.round(band.toNoise() * width);
|
||||
canvas.setColor(new Color(band.option().color()));
|
||||
canvas.fillRect(bandLeft, bandTop, Math.max(1, bandRight - bandLeft), bandHeight);
|
||||
if (band.toNoise() < 1D) {
|
||||
canvas.setColor(BAND_DIVIDER);
|
||||
canvas.drawLine(bandRight, histogramTop, bandRight, bandTop + bandHeight - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawTable(Graphics2D canvas, int x, int top, int width) {
|
||||
int columnGap = StudioTheme.scale(10);
|
||||
int actualRight = x + width;
|
||||
int weightRight = actualRight - textMetrics().stringWidth(WIDEST_SHARE) - columnGap;
|
||||
int nameRight = weightRight - textMetrics().stringWidth(WIDEST_SHARE) - columnGap;
|
||||
int titleBaseline = top + sectionMetrics().getAscent();
|
||||
drawSectionTitle(canvas, optionNoun, x, titleBaseline);
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
drawRightAligned(canvas, "WEIGHT", weightRight, titleBaseline);
|
||||
drawRightAligned(canvas, "IN VIEW", actualRight, titleBaseline);
|
||||
|
||||
int rowHeight = rowHeight();
|
||||
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
for (NoisePickSpread.Row row : shownRows()) {
|
||||
int baseline = drawOptionName(canvas, row, x, rowTop, nameRight);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
drawRightAligned(canvas, percent(row.weightShare()), weightRight, baseline);
|
||||
boolean farFromWeight = row.isFarBelowWeight() || row.isFarAboveWeight();
|
||||
canvas.setColor(farFromWeight ? StudioTheme.WARNING : StudioTheme.TEXT);
|
||||
drawRightAligned(canvas, actualText(row), actualRight, baseline);
|
||||
rowTop += rowHeight;
|
||||
}
|
||||
showAllToggleBounds = isLongTable() ? drawShowAllToggle(canvas, x, rowTop) : null;
|
||||
}
|
||||
|
||||
// Returns the row's text baseline.
|
||||
private int drawOptionName(Graphics2D canvas, NoisePickSpread.Row row, int x, int rowTop, int nameRight) {
|
||||
FontMetrics text = textMetrics();
|
||||
int rowHeight = rowHeight();
|
||||
int swatchSize = text.getAscent() - text.getDescent();
|
||||
int baseline = rowTop + (rowHeight + text.getAscent() - text.getDescent()) / 2;
|
||||
int arc = StudioTheme.scale(3);
|
||||
canvas.setColor(new Color(row.option().color()));
|
||||
canvas.fillRoundRect(x, rowTop + (rowHeight - swatchSize) / 2, swatchSize, swatchSize, arc, arc);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
int nameLeft = x + swatchSize + StudioTheme.scale(7);
|
||||
canvas.drawString(fit(canvas, distinctName(row, rows), nameRight - nameLeft), nameLeft, baseline);
|
||||
return baseline;
|
||||
}
|
||||
|
||||
private Rectangle drawShowAllToggle(Graphics2D canvas, int x, int rowTop) {
|
||||
String text = showsAllRows
|
||||
? "Show fewer"
|
||||
: "Show all " + rows.size() + " " + optionNoun.toLowerCase(Locale.ROOT);
|
||||
FontMetrics metrics = textMetrics();
|
||||
int baseline = rowTop + (rowHeight() + metrics.getAscent() - metrics.getDescent()) / 2;
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.LINK);
|
||||
canvas.drawString(text, x, baseline);
|
||||
return new Rectangle(x, rowTop, metrics.stringWidth(text), rowHeight());
|
||||
}
|
||||
|
||||
private int rowHeight() {
|
||||
return textMetrics().getHeight() + StudioTheme.scale(4);
|
||||
}
|
||||
|
||||
private FontMetrics textMetrics() {
|
||||
return getFontMetrics(StudioTheme.font());
|
||||
}
|
||||
|
||||
private FontMetrics smallMetrics() {
|
||||
return getFontMetrics(StudioTheme.smallFont());
|
||||
}
|
||||
|
||||
private FontMetrics sectionMetrics() {
|
||||
return getFontMetrics(StudioTheme.sectionFont());
|
||||
}
|
||||
|
||||
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) {
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(title.toUpperCase(Locale.ROOT), x, y);
|
||||
}
|
||||
|
||||
private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) {
|
||||
canvas.drawString(text, right - canvas.getFontMetrics().stringWidth(text), y);
|
||||
}
|
||||
|
||||
// Two biomes in different folders can share a name, so their keys tell them apart.
|
||||
private static String distinctName(NoisePickSpread.Row row, List<NoisePickSpread.Row> rows) {
|
||||
String name = row.option().name();
|
||||
boolean shared = rows.stream().anyMatch(other -> other != row && other.option().name().equals(name));
|
||||
return shared ? name + " (" + row.option().key() + ")" : name;
|
||||
}
|
||||
|
||||
private static String actualText(NoisePickSpread.Row row) {
|
||||
if (row.isFarBelowWeight()) {
|
||||
return "▼ " + percent(row.actualShare());
|
||||
}
|
||||
if (row.isFarAboveWeight()) {
|
||||
return "▲ " + percent(row.actualShare());
|
||||
}
|
||||
return percent(row.actualShare());
|
||||
}
|
||||
|
||||
static String percent(double share) {
|
||||
if (share > 0D && share < 0.001D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
}
|
||||
|
||||
private static String fit(Graphics2D canvas, String text, int maxWidth) {
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
if (metrics.stringWidth(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
String shortened = text;
|
||||
while (shortened.length() > 1 && metrics.stringWidth(shortened + "…") > maxWidth) {
|
||||
shortened = shortened.substring(0, shortened.length() - 1);
|
||||
}
|
||||
return shortened + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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 java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* How a noise picks one of several regions or biomes, measured against the noise values in view: rarity splits the
|
||||
* noise from 0 to 1 into one band per option, and each spot gets the option whose band its noise value falls in.
|
||||
*/
|
||||
final class NoisePickSpread {
|
||||
static final int HISTOGRAM_BINS = 50;
|
||||
|
||||
private final String title;
|
||||
private final List<Band> bands;
|
||||
private final long[] histogram = new long[HISTOGRAM_BINS];
|
||||
private final long[] samplesByBand;
|
||||
private long sampleCount;
|
||||
private long belowZeroCount;
|
||||
private long aboveOneCount;
|
||||
|
||||
private NoisePickSpread(String title, List<Band> bands) {
|
||||
this.title = title;
|
||||
this.bands = List.copyOf(bands);
|
||||
this.samplesByBand = new long[bands.size()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param legacyRarity whether the dimension splits the noise the legacy way
|
||||
*/
|
||||
static NoisePickSpread of(String title, List<Option> options, boolean legacyRarity) {
|
||||
return new NoisePickSpread(title, splitNoise(options, legacyRarity));
|
||||
}
|
||||
|
||||
/**
|
||||
* The band of noise each option gets, in the order the noise meets them.
|
||||
*/
|
||||
static List<Band> splitNoise(List<Option> options, boolean legacyRarity) {
|
||||
return legacyRarity ? legacyBands(options) : bands(options);
|
||||
}
|
||||
|
||||
void add(double noise) {
|
||||
if (bands.isEmpty() || !Double.isFinite(noise)) {
|
||||
return;
|
||||
}
|
||||
sampleCount++;
|
||||
if (noise < 0D) {
|
||||
belowZeroCount++;
|
||||
} else if (noise > 1D) {
|
||||
aboveOneCount++;
|
||||
}
|
||||
histogram[Math.clamp((int) (noise * HISTOGRAM_BINS), 0, HISTOGRAM_BINS - 1)]++;
|
||||
samplesByBand[bandIndexOf(noise)]++;
|
||||
}
|
||||
|
||||
String title() {
|
||||
return title;
|
||||
}
|
||||
|
||||
List<Band> bands() {
|
||||
return bands;
|
||||
}
|
||||
|
||||
long[] histogram() {
|
||||
return histogram;
|
||||
}
|
||||
|
||||
long sampleCount() {
|
||||
return sampleCount;
|
||||
}
|
||||
|
||||
double belowZeroShare() {
|
||||
return share(belowZeroCount);
|
||||
}
|
||||
|
||||
double aboveOneShare() {
|
||||
return share(aboveOneCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every option once, with its bands' widths and samples added up, in the order the noise meets them.
|
||||
*/
|
||||
List<Row> rows() {
|
||||
Map<String, Row> rows = new LinkedHashMap<>();
|
||||
for (int index = 0; index < bands.size(); index++) {
|
||||
Band band = bands.get(index);
|
||||
Row row = new Row(band.option(), band.width(), share(samplesByBand[index]));
|
||||
rows.merge(band.option().key(), row, Row::plus);
|
||||
}
|
||||
return new ArrayList<>(rows.values());
|
||||
}
|
||||
|
||||
private double share(long count) {
|
||||
return sampleCount == 0 ? 0D : count / (double) sampleCount;
|
||||
}
|
||||
|
||||
// The engine gives values below 0 the first option and values above 1 the last, the same as these bands.
|
||||
private int bandIndexOf(double noise) {
|
||||
for (int index = 0; index < bands.size(); index++) {
|
||||
if (noise <= bands.get(index).toNoise()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return bands.size() - 1;
|
||||
}
|
||||
|
||||
// Mirrors IRare.pick: each option gets a share of the noise in proportion to one over its rarity.
|
||||
private static List<Band> bands(List<Option> options) {
|
||||
double total = 0D;
|
||||
for (Option option : options) {
|
||||
total += 1D / option.rarity();
|
||||
}
|
||||
List<Band> bands = new ArrayList<>();
|
||||
double from = 0D;
|
||||
for (Option option : options) {
|
||||
double to = options.size() == 1 ? 1D : from + (1D / option.rarity()) / total;
|
||||
bands.add(new Band(option, from, to));
|
||||
from = to;
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
// Mirrors IRare.pickLegacy: each option gets a share in proportion to the total rarity minus its own.
|
||||
private static List<Band> legacyBands(List<Option> options) {
|
||||
if (options.size() <= 1) {
|
||||
return bands(options);
|
||||
}
|
||||
double totalRarity = 0D;
|
||||
for (Option option : options) {
|
||||
totalRarity += option.rarity();
|
||||
}
|
||||
double whole = totalRarity * (options.size() - 1);
|
||||
List<Band> bands = new ArrayList<>();
|
||||
double from = 0D;
|
||||
double buffer = 0D;
|
||||
for (Option option : options) {
|
||||
buffer += totalRarity - option.rarity();
|
||||
double to = buffer / whole;
|
||||
bands.add(new Band(option, from, to));
|
||||
from = to;
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
/**
|
||||
* A region or biome the noise can pick.
|
||||
*/
|
||||
record Option(String key, String name, int color, int rarity) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The noise values from {@code fromNoise} to {@code toNoise} that pick the option.
|
||||
*/
|
||||
record Band(Option option, double fromNoise, double toNoise) {
|
||||
double width() {
|
||||
return toNoise - fromNoise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param weightShare how much of the noise from 0 to 1 picks the option
|
||||
* @param actualShare how much of the noise in view picks the option
|
||||
*/
|
||||
record Row(Option option, double weightShare, double actualShare) {
|
||||
private static final double FAR_FROM_WEIGHT_FACTOR = 2D;
|
||||
|
||||
Row plus(Row other) {
|
||||
return new Row(option, weightShare + other.weightShare, actualShare + other.actualShare);
|
||||
}
|
||||
|
||||
boolean isFarBelowWeight() {
|
||||
return actualShare * FAR_FROM_WEIGHT_FACTOR < weightShare;
|
||||
}
|
||||
|
||||
boolean isFarAboveWeight() {
|
||||
return actualShare > weightShare * FAR_FROM_WEIGHT_FACTOR;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* 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.util.function.NoiseProvider;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
private static final int BANDS_PER_WORKER = 4;
|
||||
|
||||
private final int workerCount;
|
||||
private final Listener listener;
|
||||
private final ThreadPoolExecutor workers;
|
||||
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
|
||||
private final AtomicLong latestRevision = new AtomicLong();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
NoiseRenderCoordinator(Listener listener) {
|
||||
this(Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)), listener);
|
||||
}
|
||||
|
||||
NoiseRenderCoordinator(int workerCount, Listener listener) {
|
||||
this.workerCount = Math.max(1, workerCount);
|
||||
this.listener = Objects.requireNonNull(listener, "listener");
|
||||
AtomicInteger threadIds = new AtomicInteger();
|
||||
ThreadFactory workerThreadFactory = runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Noise Renderer " + threadIds.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
};
|
||||
workers = new ThreadPoolExecutor(this.workerCount, this.workerCount, 30L, TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(), workerThreadFactory);
|
||||
workers.allowCoreThreadTimeOut(true);
|
||||
GuiHost.interruptOnPluginShutdown(workers);
|
||||
}
|
||||
|
||||
void request(Request request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
latestRevision.accumulateAndGet(request.revision(), Math::max);
|
||||
RenderGeneration generation = new RenderGeneration(request, Math.min(workerCount * BANDS_PER_WORKER, outputHeight(request)));
|
||||
latestRequested.set(generation);
|
||||
listener.onRenderStarted(request);
|
||||
for (int band = 0; band < generation.bandCount(); band++) {
|
||||
int bandIndex = band;
|
||||
try {
|
||||
workers.execute(() -> renderBand(generation, bandIndex));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
generation.fail(rejected);
|
||||
finishBand(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cancel(long revision) {
|
||||
latestRevision.accumulateAndGet(revision, Math::max);
|
||||
latestRequested.set(null);
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
static int sampleStepForBudget(int width, int height, long sampleBudget) {
|
||||
if (width < 1 || height < 1 || sampleBudget < 1L) {
|
||||
throw new IllegalArgumentException("Invalid noise sample budget");
|
||||
}
|
||||
long totalSamples = (long) width * height;
|
||||
int sampleStep = Math.max(1, (int) Math.ceil(Math.sqrt(totalSamples / (double) sampleBudget)));
|
||||
while (sampleCount(width, height, sampleStep) > sampleBudget) {
|
||||
sampleStep++;
|
||||
}
|
||||
return sampleStep;
|
||||
}
|
||||
|
||||
static long sampleCount(int width, int height, int sampleStep) {
|
||||
if (width < 1 || height < 1 || sampleStep < 1) {
|
||||
throw new IllegalArgumentException("Invalid noise sample dimensions");
|
||||
}
|
||||
long outputWidth = ((long) width + sampleStep - 1L) / sampleStep;
|
||||
long outputHeight = ((long) height + sampleStep - 1L) / sampleStep;
|
||||
return outputWidth * outputHeight;
|
||||
}
|
||||
|
||||
static int nextRefinementStep(int width, int height, int currentStep, long sampleBudget) {
|
||||
if (currentStep <= 1) {
|
||||
throw new IllegalArgumentException("Noise refinement requires a coarse input");
|
||||
}
|
||||
return Math.min(currentStep, sampleStepForBudget(width, height, sampleBudget));
|
||||
}
|
||||
|
||||
static long timeBoundSampleBudget(long completedSamples, double milliseconds, double targetMilliseconds,
|
||||
long minimumBudget, long maximumBudget) {
|
||||
if (completedSamples < 1L
|
||||
|| !Double.isFinite(milliseconds)
|
||||
|| milliseconds <= 0D
|
||||
|| !Double.isFinite(targetMilliseconds)
|
||||
|| targetMilliseconds <= 0D
|
||||
|| minimumBudget < 1L
|
||||
|| maximumBudget < minimumBudget) {
|
||||
throw new IllegalArgumentException("Invalid timed noise sample budget");
|
||||
}
|
||||
double projectedSamples = Math.ceil((completedSamples / milliseconds) * targetMilliseconds);
|
||||
long timeBoundBudget = (long) Math.min(maximumBudget, projectedSamples);
|
||||
return Math.max(minimumBudget, timeBoundBudget);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
latestRevision.incrementAndGet();
|
||||
latestRequested.set(null);
|
||||
workers.shutdownNow();
|
||||
}
|
||||
|
||||
// Bands of a frame that was replaced are skipped, so a new frame only waits for the rows already being drawn.
|
||||
private void renderBand(RenderGeneration generation, int bandIndex) {
|
||||
try {
|
||||
if (isCurrent(generation)) {
|
||||
renderRows(generation, bandIndex);
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
generation.fail(error);
|
||||
} finally {
|
||||
finishBand(generation);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishBand(RenderGeneration generation) {
|
||||
if (!generation.completeBand() || !isCurrent(generation)) {
|
||||
return;
|
||||
}
|
||||
Throwable failure = generation.failure();
|
||||
if (failure != null) {
|
||||
listener.onRenderFailed(generation.request(), failure);
|
||||
return;
|
||||
}
|
||||
listener.onRenderCompleted(generation.result());
|
||||
}
|
||||
|
||||
private void renderRows(RenderGeneration generation, int bandIndex) {
|
||||
Request request = generation.request();
|
||||
int outputWidth = generation.outputWidth();
|
||||
int outputHeight = generation.outputHeight();
|
||||
int[] pixels = generation.pixels();
|
||||
BandStats stats = generation.bandStats(bandIndex);
|
||||
int fromY = (outputHeight * bandIndex) / generation.bandCount();
|
||||
int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
|
||||
double sampleOffset = request.sampleStep() * 0.5D;
|
||||
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
|
||||
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
|
||||
NoiseColorScale colorScale = request.colorScale();
|
||||
double paletteMinimum = colorScale.minimum();
|
||||
double paletteMaximum = colorScale.maximum();
|
||||
for (int y = fromY; y < toY; y++) {
|
||||
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
|
||||
double worldZ = request.viewport().worldZ(screenY, request.height());
|
||||
double worldX = startWorldX;
|
||||
int pixelIndex = y * outputWidth;
|
||||
boolean centerRow = y == outputHeight / 2;
|
||||
for (int x = 0; x < outputWidth; x++) {
|
||||
if ((x & 31) == 0 && (!isCurrent(generation) || Thread.currentThread().isInterrupted())) {
|
||||
return;
|
||||
}
|
||||
double value = request.sampler().noise(worldX, worldZ);
|
||||
boolean center = centerRow && x == outputWidth / 2;
|
||||
if (Double.isFinite(value)) {
|
||||
pixels[pixelIndex++] = colorScale.colorAt(value, worldX, worldZ);
|
||||
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
|
||||
} else {
|
||||
pixels[pixelIndex++] = colorScale.colorInvalidAt(worldX, worldZ);
|
||||
stats.acceptInvalid(value, center);
|
||||
}
|
||||
worldX += worldStep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrent(RenderGeneration generation) {
|
||||
return !closed.get()
|
||||
&& latestRequested.get() == generation
|
||||
&& generation.request().revision() >= latestRevision.get();
|
||||
}
|
||||
|
||||
private static int outputWidth(Request request) {
|
||||
return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
|
||||
}
|
||||
|
||||
private static int outputHeight(Request request) {
|
||||
return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
|
||||
}
|
||||
|
||||
private static RenderStats combine(BandStats[] bands) {
|
||||
long samples = 0L;
|
||||
long underflow = 0L;
|
||||
long overflow = 0L;
|
||||
long invalid = 0L;
|
||||
double minimum = Double.POSITIVE_INFINITY;
|
||||
double maximum = Double.NEGATIVE_INFINITY;
|
||||
double centerValue = Double.NaN;
|
||||
for (BandStats band : bands) {
|
||||
samples += band.samples;
|
||||
underflow += band.underflow;
|
||||
overflow += band.overflow;
|
||||
invalid += band.invalid;
|
||||
minimum = Math.min(minimum, band.minimum);
|
||||
maximum = Math.max(maximum, band.maximum);
|
||||
if (!Double.isNaN(band.centerValue)) {
|
||||
centerValue = band.centerValue;
|
||||
}
|
||||
}
|
||||
if (minimum == Double.POSITIVE_INFINITY) {
|
||||
minimum = Double.NaN;
|
||||
maximum = Double.NaN;
|
||||
}
|
||||
return new RenderStats(samples, minimum, maximum, centerValue, underflow, overflow, invalid);
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onRenderStarted(Request request);
|
||||
|
||||
void onRenderCompleted(Result result);
|
||||
|
||||
void onRenderFailed(Request request, Throwable error);
|
||||
}
|
||||
|
||||
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoiseColorScale colorScale,
|
||||
int width, int height, int sampleStep) {
|
||||
Request {
|
||||
Objects.requireNonNull(sampler, "sampler");
|
||||
Objects.requireNonNull(viewport, "viewport");
|
||||
Objects.requireNonNull(colorScale, "colorScale");
|
||||
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
|
||||
throw new IllegalArgumentException("Invalid noise render request");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record Result(Request request, BufferedImage image, double milliseconds, long samples, double minimum,
|
||||
double maximum, double centerValue, long underflow, long overflow, long invalid) {
|
||||
}
|
||||
|
||||
private static final class RenderGeneration {
|
||||
private final Request request;
|
||||
private final long startedNanos = System.nanoTime();
|
||||
private final int outputWidth;
|
||||
private final int outputHeight;
|
||||
private final int[] pixels;
|
||||
private final BandStats[] bandStats;
|
||||
private final AtomicInteger remainingBands;
|
||||
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
|
||||
private RenderGeneration(Request request, int bandCount) {
|
||||
this.request = request;
|
||||
this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
|
||||
this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
|
||||
this.pixels = new int[outputWidth * outputHeight];
|
||||
this.bandStats = new BandStats[Math.max(1, bandCount)];
|
||||
for (int band = 0; band < bandStats.length; band++) {
|
||||
bandStats[band] = new BandStats();
|
||||
}
|
||||
this.remainingBands = new AtomicInteger(bandStats.length);
|
||||
}
|
||||
|
||||
private Request request() {
|
||||
return request;
|
||||
}
|
||||
|
||||
private int bandCount() {
|
||||
return bandStats.length;
|
||||
}
|
||||
|
||||
private int outputWidth() {
|
||||
return outputWidth;
|
||||
}
|
||||
|
||||
private int outputHeight() {
|
||||
return outputHeight;
|
||||
}
|
||||
|
||||
private int[] pixels() {
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private BandStats bandStats(int band) {
|
||||
return bandStats[band];
|
||||
}
|
||||
|
||||
private void fail(Throwable error) {
|
||||
failure.compareAndSet(null, error);
|
||||
}
|
||||
|
||||
private Throwable failure() {
|
||||
return failure.get();
|
||||
}
|
||||
|
||||
// True for the band that finishes last, which is the one that publishes the frame.
|
||||
private boolean completeBand() {
|
||||
return remainingBands.decrementAndGet() == 0;
|
||||
}
|
||||
|
||||
private Result result() {
|
||||
BufferedImage image = GuiHost.uncachedImage(pixels, outputWidth, outputHeight);
|
||||
RenderStats combined = combine(bandStats);
|
||||
double milliseconds = (System.nanoTime() - startedNanos) / 1_000_000D;
|
||||
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
|
||||
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BandStats {
|
||||
private long samples;
|
||||
private long underflow;
|
||||
private long overflow;
|
||||
private long invalid;
|
||||
private double minimum = Double.POSITIVE_INFINITY;
|
||||
private double maximum = Double.NEGATIVE_INFINITY;
|
||||
private double centerValue = Double.NaN;
|
||||
|
||||
private void acceptFinite(double value, boolean center, double paletteMinimum, double paletteMaximum) {
|
||||
samples++;
|
||||
if (center) {
|
||||
centerValue = value;
|
||||
}
|
||||
minimum = Math.min(minimum, value);
|
||||
maximum = Math.max(maximum, value);
|
||||
if (value < paletteMinimum) {
|
||||
underflow++;
|
||||
} else if (value > paletteMaximum) {
|
||||
overflow++;
|
||||
}
|
||||
}
|
||||
|
||||
private void acceptInvalid(double value, boolean center) {
|
||||
samples++;
|
||||
invalid++;
|
||||
if (center) {
|
||||
centerValue = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record RenderStats(long samples, double minimum, double maximum, double centerValue,
|
||||
long underflow, long overflow, long invalid) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||
static final double MIN_BLOCKS_PER_PIXEL = 0.0001D;
|
||||
static final double MAX_BLOCKS_PER_PIXEL = 1_000_000D;
|
||||
|
||||
NoiseViewport {
|
||||
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
|
||||
throw new IllegalArgumentException("Viewport center must be finite");
|
||||
}
|
||||
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Viewport scale must be finite and positive");
|
||||
}
|
||||
}
|
||||
|
||||
double worldX(double screenX, int width) {
|
||||
return centerX + ((screenX - (width / 2D)) * blocksPerPixel);
|
||||
}
|
||||
|
||||
double worldZ(double screenZ, int height) {
|
||||
return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel);
|
||||
}
|
||||
|
||||
double screenX(double worldX, int width) {
|
||||
return ((worldX - centerX) / blocksPerPixel) + (width / 2D);
|
||||
}
|
||||
|
||||
double screenZ(double worldZ, int height) {
|
||||
return ((worldZ - centerZ) / blocksPerPixel) + (height / 2D);
|
||||
}
|
||||
|
||||
NoiseViewport panPixels(double deltaX, double deltaZ) {
|
||||
return new NoiseViewport(
|
||||
centerX - (deltaX * blocksPerPixel),
|
||||
centerZ - (deltaZ * blocksPerPixel),
|
||||
blocksPerPixel
|
||||
);
|
||||
}
|
||||
|
||||
NoiseViewport zoomAt(double screenX, double screenZ, int width, int height, double factor) {
|
||||
if (!Double.isFinite(factor) || factor <= 0D) {
|
||||
throw new IllegalArgumentException("Zoom factor must be finite and positive");
|
||||
}
|
||||
double anchorX = worldX(screenX, width);
|
||||
double anchorZ = worldZ(screenZ, height);
|
||||
double nextScale = Math.max(MIN_BLOCKS_PER_PIXEL,
|
||||
Math.min(MAX_BLOCKS_PER_PIXEL, blocksPerPixel * factor));
|
||||
double nextCenterX = anchorX - ((screenX - (width / 2D)) * nextScale);
|
||||
double nextCenterZ = anchorZ - ((screenZ - (height / 2D)) * nextScale);
|
||||
return new NoiseViewport(nextCenterX, nextCenterZ, nextScale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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 javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.ToolTipManager;
|
||||
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.Component;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Every object in the pack as a folder tree, with a search that matches every word typed anywhere in an object's path.
|
||||
*/
|
||||
final class ObjectBrowser extends JPanel {
|
||||
private static final long serialVersionUID = 6795434561028735214L;
|
||||
private static final int ROW_HEIGHT = 24;
|
||||
|
||||
private final Consumer<String> onObjectChosen;
|
||||
private final JTextField searchField = new JTextField();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
|
||||
private final JTree tree = new JTree(treeModel);
|
||||
private final JLabel countLabel = new JLabel();
|
||||
private final String noun;
|
||||
private List<String> objectKeys = List.of();
|
||||
private String chosenKey;
|
||||
private boolean updatingSelection;
|
||||
|
||||
/**
|
||||
* @param noun what one entry is called in the browser's texts, such as "object"
|
||||
*/
|
||||
ObjectBrowser(String noun, Consumer<String> onObjectChosen) {
|
||||
super(new BorderLayout());
|
||||
this.noun = noun;
|
||||
this.onObjectChosen = onObjectChosen;
|
||||
countLabel.setText("Loading " + noun + "s...");
|
||||
StudioToolbar.makeSearchField(searchField, "Search " + noun + "s...");
|
||||
searchField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
});
|
||||
tree.setRootVisible(false);
|
||||
tree.setShowsRootHandles(true);
|
||||
tree.setRowHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
|
||||
tree.setCellRenderer(new EntryRenderer());
|
||||
ToolTipManager.sharedInstance().registerComponent(tree);
|
||||
tree.addTreeSelectionListener(event -> {
|
||||
if (!updatingSelection && nodeAt(event.getPath()) instanceof Entry entry && entry.isObject()) {
|
||||
chosenKey = entry.key();
|
||||
onObjectChosen.accept(entry.key());
|
||||
}
|
||||
});
|
||||
countLabel.setFont(StudioTheme.smallFont());
|
||||
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||
add(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER);
|
||||
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
void showObjects(List<String> keys) {
|
||||
objectKeys = keys.stream().sorted(Comparator.comparing(key -> key.toLowerCase(Locale.ROOT))).toList();
|
||||
countLabel.setText(objectKeys.size() == 1 ? "1 " + noun : objectKeys.size() + " " + noun + "s");
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
void showError(String text) {
|
||||
countLabel.setText(text);
|
||||
}
|
||||
|
||||
String firstObject() {
|
||||
return objectKeys.isEmpty() ? null : objectKeys.get(0);
|
||||
}
|
||||
|
||||
private void rebuildTree() {
|
||||
String[] words = searchField.getText().trim().toLowerCase(Locale.ROOT).split("\\s+");
|
||||
boolean searching = !searchField.getText().isBlank();
|
||||
DefaultMutableTreeNode root = searching ? searchResults(words) : folderTree();
|
||||
treeModel.setRoot(root);
|
||||
if (searching) {
|
||||
expandAll();
|
||||
}
|
||||
selectChosen();
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode searchResults(String[] words) {
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
for (String key : objectKeys) {
|
||||
if (matchesEveryWord(key, words)) {
|
||||
root.add(new DefaultMutableTreeNode(Entry.object(key, key)));
|
||||
}
|
||||
}
|
||||
if (root.getChildCount() == 0) {
|
||||
root.add(new DefaultMutableTreeNode(Entry.note("No matching " + noun + "s")));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private static boolean matchesEveryWord(String key, String[] words) {
|
||||
String lowerKey = key.toLowerCase(Locale.ROOT);
|
||||
for (String word : words) {
|
||||
if (!lowerKey.contains(word)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Folders come before the objects beside them, like a file browser.
|
||||
private DefaultMutableTreeNode folderTree() {
|
||||
Folder top = new Folder();
|
||||
for (String key : objectKeys) {
|
||||
Folder folder = top;
|
||||
String[] parts = key.split("/");
|
||||
for (int index = 0; index < parts.length - 1; index++) {
|
||||
folder = folder.subfolders.computeIfAbsent(parts[index], name -> new Folder());
|
||||
}
|
||||
folder.objects.add(key);
|
||||
}
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
addFolderContents(root, top, "");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void addFolderContents(DefaultMutableTreeNode node, Folder folder, String path) {
|
||||
for (Map.Entry<String, Folder> subfolder : folder.subfolders.entrySet()) {
|
||||
String subfolderPath = path + subfolder.getKey() + "/";
|
||||
DefaultMutableTreeNode child = new DefaultMutableTreeNode(Entry.folder(subfolder.getKey(), subfolderPath));
|
||||
addFolderContents(child, subfolder.getValue(), subfolderPath);
|
||||
node.add(child);
|
||||
}
|
||||
for (String key : folder.objects) {
|
||||
node.add(new DefaultMutableTreeNode(Entry.object(key.substring(key.lastIndexOf('/') + 1), key)));
|
||||
}
|
||||
}
|
||||
|
||||
void select(String key) {
|
||||
chosenKey = key;
|
||||
selectChosen();
|
||||
}
|
||||
|
||||
private void selectChosen() {
|
||||
DefaultMutableTreeNode node = chosenKey == null ? null : findObjectNode((DefaultMutableTreeNode) treeModel.getRoot());
|
||||
updatingSelection = true;
|
||||
try {
|
||||
if (node == null) {
|
||||
tree.clearSelection();
|
||||
return;
|
||||
}
|
||||
TreePath path = new TreePath(node.getPath());
|
||||
tree.setSelectionPath(path);
|
||||
tree.scrollPathToVisible(path);
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode findObjectNode(DefaultMutableTreeNode node) {
|
||||
if (node.getUserObject() instanceof Entry entry && entry.isObject() && entry.key().equals(chosenKey)) {
|
||||
return node;
|
||||
}
|
||||
for (int index = 0; index < node.getChildCount(); index++) {
|
||||
DefaultMutableTreeNode found = findObjectNode((DefaultMutableTreeNode) node.getChildAt(index));
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void expandAll() {
|
||||
for (int row = 0; row < tree.getRowCount(); row++) {
|
||||
tree.expandRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object nodeAt(TreePath path) {
|
||||
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
|
||||
private static final class Folder {
|
||||
private final Map<String, Folder> subfolders = new LinkedHashMap<>();
|
||||
private final List<String> objects = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the tree: an object with its key, a folder with its path, or a note without either.
|
||||
*/
|
||||
private record Entry(String label, String key, String folderPath) {
|
||||
static Entry object(String label, String key) {
|
||||
return new Entry(label, key, null);
|
||||
}
|
||||
|
||||
static Entry folder(String label, String path) {
|
||||
return new Entry(label, null, path);
|
||||
}
|
||||
|
||||
static Entry note(String text) {
|
||||
return new Entry(text, null, null);
|
||||
}
|
||||
|
||||
boolean isObject() {
|
||||
return key != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class EntryRenderer extends DefaultTreeCellRenderer {
|
||||
private static final long serialVersionUID = -4316652201793418740L;
|
||||
|
||||
private EntryRenderer() {
|
||||
setLeafIcon(null);
|
||||
setOpenIcon(null);
|
||||
setClosedIcon(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
|
||||
boolean leaf, int row, boolean focus) {
|
||||
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
super.getTreeCellRendererComponent(tree, userObject == null ? "" : userObject.toString(), selected, expanded, leaf, row, false);
|
||||
boolean isNote = userObject instanceof Entry entry && !entry.isObject() && entry.folderPath() == null;
|
||||
setFont(StudioTheme.font());
|
||||
setForeground(isNote ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||
setToolTipText(userObject instanceof Entry entry ? entry.isObject() ? entry.key() : entry.folderPath() : null);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Where the object viewer looks from: turned by yaw around the vertical axis, tilted down by pitch, both in radians.
|
||||
* Zoom 1 fits the object in the view at the starting angle. Pan moves the picture in fractions of the view's size.
|
||||
*/
|
||||
record ObjectCamera(double yaw, double pitch, double zoom, double panX, double panY) {
|
||||
static final ObjectCamera START = new ObjectCamera(0.76D, 0.38D, 1.72D, 0D, 0.025D);
|
||||
private static final double RADIANS_PER_DRAGGED_PIXEL = 0.008D;
|
||||
private static final double MAXIMUM_PITCH = 1.3D;
|
||||
private static final double MINIMUM_ZOOM = 0.4D;
|
||||
private static final double MAXIMUM_ZOOM = 12D;
|
||||
|
||||
ObjectCamera orbitedBy(double draggedX, double draggedY) {
|
||||
double nextPitch = Math.max(-MAXIMUM_PITCH, Math.min(MAXIMUM_PITCH, pitch + draggedY * RADIANS_PER_DRAGGED_PIXEL));
|
||||
return new ObjectCamera(yaw + draggedX * RADIANS_PER_DRAGGED_PIXEL, nextPitch, zoom, panX, panY);
|
||||
}
|
||||
|
||||
ObjectCamera zoomedBy(double factor) {
|
||||
return new ObjectCamera(yaw, pitch, Math.max(MINIMUM_ZOOM, Math.min(MAXIMUM_ZOOM, zoom * factor)), panX, panY);
|
||||
}
|
||||
|
||||
ObjectCamera pannedBy(double fractionX, double fractionY) {
|
||||
return new ObjectCamera(yaw, pitch, zoom, panX + fractionX, panY + fractionY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
* 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.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisObject;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.BlockVector;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JPanel;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* The map window's Objects and Procedural views: everything its source offers in a folder tree, the chosen one in 3D,
|
||||
* and what it is made of. A procedural object's variants can also be compared side by side, baked from any seed.
|
||||
* Inspired by the Procedural Objects workbench.
|
||||
*/
|
||||
final class ObjectExplorer {
|
||||
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
|
||||
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
|
||||
private static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
|
||||
private static final String ONE_VARIANT_CARD = "one variant";
|
||||
private static final String ALL_VARIANTS_CARD = "all variants";
|
||||
|
||||
private final Engine startEngine;
|
||||
private final UUID worldId;
|
||||
private final ObjectSource source;
|
||||
private final ObjectBrowser browser;
|
||||
private final ObjectViewport viewport = new ObjectViewport();
|
||||
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> showLayerCut());
|
||||
private final ObjectVariantPicker variantPicker = new ObjectVariantPicker(this::showVariant, this::showSeed, this::toggleComparing);
|
||||
private final ObjectVariantGallery gallery = new ObjectVariantGallery(this::openVariant, this::redrawThumbnails);
|
||||
private final CardLayout viewCards = new CardLayout();
|
||||
private final JPanel views = new JPanel(viewCards);
|
||||
private final JComponent viewWithOverlays;
|
||||
private final ObjectOverviewPanel overview = new ObjectOverviewPanel(this::showSeeThrough);
|
||||
private final StudioStatusBar statusBar = new StudioStatusBar();
|
||||
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Object Loader");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
private final ExecutorService thumbnailPainter = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Variant Thumbnails");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicLong objectRevision = new AtomicLong();
|
||||
private final AtomicLong variantListRevision = new AtomicLong();
|
||||
private final AtomicLong meshRevision = new AtomicLong();
|
||||
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
|
||||
private final Runnable hotloadHook = this::reloadPack;
|
||||
private volatile BlockTextures textures;
|
||||
private volatile BiomeTintColors tintColors;
|
||||
private String shownKey;
|
||||
private List<IrisObject> shownVariants = List.of();
|
||||
private int shownVariantIndex;
|
||||
private OptionalLong packSeed = OptionalLong.empty();
|
||||
private OptionalLong shownSeed = OptionalLong.empty();
|
||||
private boolean comparingVariants;
|
||||
private long thumbnailsDrawnForRevision = -1;
|
||||
private LoadedObject shown;
|
||||
private ObjectMesh wholeMesh;
|
||||
private Set<String> seeThroughTypes = Set.of();
|
||||
|
||||
ObjectExplorer(Engine engine, UUID worldId, ObjectSource source) {
|
||||
this.startEngine = engine;
|
||||
this.worldId = worldId;
|
||||
this.source = source;
|
||||
this.browser = new ObjectBrowser(source.noun(), this::showObject);
|
||||
views.add(layerCut.floatingOver(viewport), ONE_VARIANT_CARD);
|
||||
views.add(gallery, ALL_VARIANTS_CARD);
|
||||
this.viewWithOverlays = variantPicker.floatingOver(views);
|
||||
GuiHost.interruptOnPluginShutdown(loader);
|
||||
GuiHost.interruptOnPluginShutdown(thumbnailPainter);
|
||||
GuiHost.registerHotloadHook(hotloadHook);
|
||||
statusBar.showPosition(CAMERA_HINT);
|
||||
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
|
||||
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
|
||||
viewport.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
|
||||
viewport.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
|
||||
viewport.bindKey(KeyEvent.VK_G, "compare-variants", this::toggleComparing);
|
||||
gallery.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
|
||||
gallery.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
|
||||
gallery.bindKey(KeyEvent.VK_G, "show-one-variant", this::toggleComparing);
|
||||
gallery.bindKey(KeyEvent.VK_ESCAPE, "close-comparison", this::toggleComparing);
|
||||
}
|
||||
|
||||
JComponent browser() {
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3D view with the slider that cuts away its top layers, or every variant side by side, under the bar that
|
||||
* steps through variants and seeds.
|
||||
*/
|
||||
JComponent view() {
|
||||
return viewWithOverlays;
|
||||
}
|
||||
|
||||
void setCoveredLeftWidth(int width) {
|
||||
viewport.setCoveredLeftWidth(width);
|
||||
gallery.setCoveredLeftWidth(width);
|
||||
variantPicker.setCoveredLeftWidth(width);
|
||||
}
|
||||
|
||||
JComponent overview() {
|
||||
return overview;
|
||||
}
|
||||
|
||||
StudioStatusBar statusBar() {
|
||||
return statusBar;
|
||||
}
|
||||
|
||||
void resetCamera() {
|
||||
viewport.resetCamera();
|
||||
}
|
||||
|
||||
// Textures load first, since the first object would otherwise be drawn in flat colours.
|
||||
void open() {
|
||||
viewport.showLoading("Loading Minecraft textures");
|
||||
loader.execute(() -> {
|
||||
loadTextures();
|
||||
List<String> keys = objectKeys();
|
||||
EventQueue.invokeLater(() -> showCatalog(keys));
|
||||
});
|
||||
}
|
||||
|
||||
void close() {
|
||||
GuiHost.unregisterHotloadHook(hotloadHook);
|
||||
objectRevision.incrementAndGet();
|
||||
loader.shutdownNow();
|
||||
thumbnailPainter.shutdownNow();
|
||||
viewport.close();
|
||||
}
|
||||
|
||||
private void loadTextures() {
|
||||
try {
|
||||
BlockTextures loaded = BlockTextures.shared().join();
|
||||
tintColors = loaded.biomeColors().vanilla(TINT_BIOME);
|
||||
textures = loaded;
|
||||
} catch (RuntimeException unavailable) {
|
||||
EventQueue.invokeLater(() -> statusBar.showError("Minecraft textures are unavailable, objects use plain colours"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> objectKeys() {
|
||||
return source.keys(engine().getData());
|
||||
}
|
||||
|
||||
private void showCatalog(List<String> keys) {
|
||||
statusBar.showActivity("");
|
||||
browser.showObjects(keys);
|
||||
String key = shownKey != null && keys.contains(shownKey) ? shownKey : browser.firstObject();
|
||||
if (key == null) {
|
||||
viewport.showEmptyState(source.emptyState());
|
||||
overview.clear();
|
||||
layerCut.showHeight(1);
|
||||
if (comparingVariants) {
|
||||
toggleComparing();
|
||||
}
|
||||
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
|
||||
return;
|
||||
}
|
||||
browser.select(key);
|
||||
showObject(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the first variant of the entry, baked from the pack's seed. The pack reloading the shown entry keeps the
|
||||
* variant and the seed being looked at, unless the pack itself now sets another seed.
|
||||
*/
|
||||
private void showObject(String key) {
|
||||
boolean reloadingShownObject = key.equals(shownKey);
|
||||
OptionalLong previousPackSeed = packSeed;
|
||||
OptionalLong exploredSeed = shownSeed;
|
||||
int variantIndex = reloadingShownObject ? shownVariantIndex : 0;
|
||||
shownKey = key;
|
||||
if (!reloadingShownObject) {
|
||||
variantPicker.forgetTriedSeeds();
|
||||
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
|
||||
}
|
||||
long revision = startBaking(key);
|
||||
loader.execute(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
OptionalLong pack = source.seed(key);
|
||||
OptionalLong seed = reloadingShownObject && pack.equals(previousPackSeed) ? exploredSeed : pack;
|
||||
loadVariants(revision, key, pack, seed, variantIndex);
|
||||
});
|
||||
}
|
||||
|
||||
// The seed shows at once, so stepping again continues from it even while this one bakes or fails.
|
||||
private void showSeed(long seed) {
|
||||
String key = shownKey;
|
||||
OptionalLong pack = packSeed;
|
||||
int variantIndex = shownVariantIndex;
|
||||
shownSeed = OptionalLong.of(seed);
|
||||
variantPicker.showSeed(shownSeed, pack);
|
||||
long revision = startBaking(key + " with seed " + seed);
|
||||
loader.execute(() -> {
|
||||
if (revision == objectRevision.get()) {
|
||||
loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On the loader thread.
|
||||
private void loadVariants(long revision, String key, OptionalLong pack, OptionalLong seed, int preferredIndex) {
|
||||
try {
|
||||
IrisData data = engine().getData();
|
||||
List<IrisObject> variants = seed.isPresent() ? source.variants(data, key, seed.getAsLong()) : source.variants(data, key);
|
||||
if (variants.isEmpty()) {
|
||||
throw new IllegalStateException("Nothing could be loaded for " + key);
|
||||
}
|
||||
int index = preferredIndex < variants.size() ? preferredIndex : 0;
|
||||
LoadedObject loaded = load(variants.get(index));
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
showVariantList(variants, pack, seed, index);
|
||||
showLoaded(revision, loaded);
|
||||
});
|
||||
} catch (RuntimeException error) {
|
||||
EventQueue.invokeLater(() -> showFailure(revision, key, error));
|
||||
}
|
||||
}
|
||||
|
||||
private void showVariantList(List<IrisObject> variants, OptionalLong pack, OptionalLong seed, int index) {
|
||||
shownVariants = variants;
|
||||
shownVariantIndex = index;
|
||||
packSeed = pack;
|
||||
shownSeed = seed;
|
||||
variantListRevision.incrementAndGet();
|
||||
variantPicker.showVariants(variants.size(), index);
|
||||
variantPicker.showSeed(seed, pack);
|
||||
if (!comparingVariants) {
|
||||
return;
|
||||
}
|
||||
if (variants.isEmpty()) {
|
||||
gallery.showLoading();
|
||||
} else if (variants.size() == 1) {
|
||||
toggleComparing();
|
||||
} else {
|
||||
drawThumbnails();
|
||||
}
|
||||
}
|
||||
|
||||
private void showVariant(int index) {
|
||||
if (index >= shownVariants.size()) {
|
||||
return;
|
||||
}
|
||||
shownVariantIndex = index;
|
||||
gallery.select(index);
|
||||
IrisObject variant = shownVariants.get(index);
|
||||
long revision = startLoading(variant.getLoadKey());
|
||||
loader.execute(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
LoadedObject loaded = load(variant);
|
||||
EventQueue.invokeLater(() -> showLoaded(revision, loaded));
|
||||
});
|
||||
}
|
||||
|
||||
private void openVariant(int index) {
|
||||
variantPicker.showVariants(shownVariants.size(), index);
|
||||
showVariant(index);
|
||||
toggleComparing();
|
||||
}
|
||||
|
||||
private void toggleComparing() {
|
||||
if (!comparingVariants && shownVariants.size() < 2) {
|
||||
return;
|
||||
}
|
||||
comparingVariants = !comparingVariants;
|
||||
variantPicker.showComparing(comparingVariants);
|
||||
viewCards.show(views, comparingVariants ? ALL_VARIANTS_CARD : ONE_VARIANT_CARD);
|
||||
if (comparingVariants) {
|
||||
drawThumbnails();
|
||||
gallery.requestFocusInWindow();
|
||||
} else {
|
||||
viewport.requestFocusInWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private void redrawThumbnails() {
|
||||
thumbnailsDrawnForRevision = -1;
|
||||
if (comparingVariants) {
|
||||
drawThumbnails();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the side-by-side view with every shown variant, all drawn at the scale that fits the largest, so their
|
||||
* sizes compare. Thumbnails arrive one at a time on their own thread, leaving the loader free for the 3D view.
|
||||
*/
|
||||
private void drawThumbnails() {
|
||||
long revision = variantListRevision.get();
|
||||
if (revision == thumbnailsDrawnForRevision) {
|
||||
return;
|
||||
}
|
||||
thumbnailsDrawnForRevision = revision;
|
||||
List<IrisObject> variants = shownVariants;
|
||||
gallery.showVariants(galleryHeading(), variants.size(), shownVariantIndex);
|
||||
int size = gallery.thumbnailSize();
|
||||
thumbnailPainter.execute(() -> {
|
||||
List<ObjectMesh> meshes = new ArrayList<>();
|
||||
for (IrisObject variant : variants) {
|
||||
if (revision != variantListRevision.get()) {
|
||||
return;
|
||||
}
|
||||
meshes.add(ObjectMesh.build(visibleBlocks(variant), this::modelOf));
|
||||
}
|
||||
ObjectMesh.Bounds sizeToFit = largestSize(meshes);
|
||||
for (int index = 0; index < meshes.size() && revision == variantListRevision.get(); index++) {
|
||||
BufferedImage thumbnail = ObjectRenderer.renderThumbnail(meshes.get(index), sizeToFit, size, size);
|
||||
int shownIndex = index;
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision == variantListRevision.get()) {
|
||||
gallery.showThumbnail(shownIndex, thumbnail);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String galleryHeading() {
|
||||
return String.format(Locale.ROOT, "%s · %d variants%s", shownKey, shownVariants.size(), seedNote());
|
||||
}
|
||||
|
||||
private static ObjectMesh.Bounds largestSize(List<ObjectMesh> meshes) {
|
||||
int sizeX = 1;
|
||||
int sizeY = 1;
|
||||
int sizeZ = 1;
|
||||
for (ObjectMesh mesh : meshes) {
|
||||
sizeX = Math.max(sizeX, mesh.bounds().sizeX());
|
||||
sizeY = Math.max(sizeY, mesh.bounds().sizeY());
|
||||
sizeZ = Math.max(sizeZ, mesh.bounds().sizeZ());
|
||||
}
|
||||
return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ);
|
||||
}
|
||||
|
||||
// A new list of variants is on its way, so every thumbnail is outdated, unlike when stepping to another variant.
|
||||
private long startBaking(String key) {
|
||||
if (comparingVariants) {
|
||||
gallery.showLoading();
|
||||
}
|
||||
return startLoading(key);
|
||||
}
|
||||
|
||||
private long startLoading(String key) {
|
||||
shown = null;
|
||||
wholeMesh = null;
|
||||
meshRevision.incrementAndGet();
|
||||
viewport.showLoading("Loading " + key);
|
||||
return objectRevision.incrementAndGet();
|
||||
}
|
||||
|
||||
private LoadedObject load(IrisObject object) {
|
||||
Map<BlockVector, BlockData> blocks = visibleBlocks(object);
|
||||
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
|
||||
ObjectSummary summary = ObjectSummary.of(object.getLoadKey(), mesh.bounds(), blocks.values(), this::iconOf);
|
||||
return new LoadedObject(blocks, mesh, summary);
|
||||
}
|
||||
|
||||
private static Map<BlockVector, BlockData> visibleBlocks(IrisObject object) {
|
||||
Map<BlockVector, BlockData> blocks = new HashMap<>();
|
||||
object.getBlocks().forEach((position, block) -> {
|
||||
if (!block.getMaterial().isAir()) {
|
||||
blocks.put(position, block);
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
|
||||
private void showLoaded(long revision, LoadedObject loaded) {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
shown = loaded;
|
||||
wholeMesh = loaded.mesh();
|
||||
seeThroughTypes = Set.of();
|
||||
statusBar.showActivity("");
|
||||
viewport.show(loaded.mesh());
|
||||
overview.show(loaded.summary());
|
||||
layerCut.showHeight(loaded.mesh().bounds().sizeY());
|
||||
showMeshSummary(loaded.mesh());
|
||||
}
|
||||
|
||||
private void showMeshSummary(ObjectMesh mesh) {
|
||||
ObjectSummary summary = shown.summary();
|
||||
String cut = layerCut.isCutting()
|
||||
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
|
||||
: "";
|
||||
statusBar.showSummary(String.format(Locale.ROOT, "%s%s · %,d blocks · %,d faces drawn%s",
|
||||
summary.key(), seedNote(), summary.blockCount(), mesh.faceCount(), cut));
|
||||
}
|
||||
|
||||
// A seed being tried is not in the pack until its "seed" field says so.
|
||||
private String seedNote() {
|
||||
if (shownSeed.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String note = " · seed " + shownSeed.getAsLong();
|
||||
return shownSeed.equals(packSeed) ? note : note + " (the pack has " + packSeed.getAsLong() + ")";
|
||||
}
|
||||
|
||||
private void showSeeThrough(Set<String> blockTypes) {
|
||||
seeThroughTypes = blockTypes;
|
||||
rebuildWholeMesh();
|
||||
}
|
||||
|
||||
// Blocks behind see-through ones get their hidden faces back, so the mesh is built again.
|
||||
private void rebuildWholeMesh() {
|
||||
LoadedObject object = shown;
|
||||
if (object == null) {
|
||||
return;
|
||||
}
|
||||
long revision = meshRevision.incrementAndGet();
|
||||
Set<String> seeThrough = seeThroughTypes;
|
||||
loader.execute(() -> {
|
||||
if (revision != meshRevision.get()) {
|
||||
return;
|
||||
}
|
||||
ObjectMesh mesh = seeThrough.isEmpty()
|
||||
? object.mesh()
|
||||
: ObjectMesh.build(object.blocks(), this::modelOf, object.mesh().bounds(),
|
||||
block -> seeThrough.contains(block.getMaterial().getKey().getKey()));
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision == meshRevision.get() && shown == object) {
|
||||
wholeMesh = mesh;
|
||||
showLayerCut();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showLayerCut() {
|
||||
if (wholeMesh == null) {
|
||||
return;
|
||||
}
|
||||
int highestShownY = wholeMesh.bounds().minY() + layerCut.shownLayerCount() - 1;
|
||||
ObjectMesh mesh = layerCut.isCutting() ? wholeMesh.cutAbove(highestShownY) : wholeMesh;
|
||||
viewport.replaceMesh(mesh);
|
||||
showMeshSummary(mesh);
|
||||
}
|
||||
|
||||
private void showFailure(long revision, String key, Throwable error) {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
statusBar.showActivity("");
|
||||
viewport.showMessage("Could not load " + key);
|
||||
gallery.showMessage("Could not load " + key);
|
||||
thumbnailsDrawnForRevision = -1;
|
||||
overview.clear();
|
||||
layerCut.showHeight(1);
|
||||
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
|
||||
}
|
||||
|
||||
// The pack's files may have changed, so the list and the shown object are read again.
|
||||
private void reloadPack() {
|
||||
loader.execute(() -> {
|
||||
List<String> keys = objectKeys();
|
||||
EventQueue.invokeLater(() -> showCatalog(keys));
|
||||
});
|
||||
}
|
||||
|
||||
private Engine engine() {
|
||||
Engine current = GuiHost.findEngine(worldId);
|
||||
return current != null && !current.isClosed() ? current : startEngine;
|
||||
}
|
||||
|
||||
private BlockModel modelOf(BlockData block) {
|
||||
return models.computeIfAbsent(block.getAsString(false), state -> {
|
||||
BlockTextures available = textures;
|
||||
if (available == null) {
|
||||
return BlockModel.solidColor(UNKNOWN_BLOCK_COLOR);
|
||||
}
|
||||
return available.model(block).map(this::withBiomeTint).orElseGet(() -> BlockModel.solidColor(UNKNOWN_BLOCK_COLOR));
|
||||
});
|
||||
}
|
||||
|
||||
private BufferedImage iconOf(BlockData block) {
|
||||
BlockTextures available = textures;
|
||||
if (available == null) {
|
||||
return null;
|
||||
}
|
||||
return available.iconTexture(block).map(this::withBiomeTint).map(BlockTexture::toImage).orElse(null);
|
||||
}
|
||||
|
||||
// Objects have no biome of their own, so grass and leaves get plains' colours.
|
||||
private BlockModel withBiomeTint(BlockModel model) {
|
||||
return model.withTextures(this::withBiomeTint);
|
||||
}
|
||||
|
||||
private BlockTexture withBiomeTint(BlockTexture texture) {
|
||||
BiomeTintColors colors = tintColors;
|
||||
if (texture.biomeTint() == null || colors == null) {
|
||||
return texture;
|
||||
}
|
||||
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
|
||||
}
|
||||
|
||||
private record LoadedObject(Map<BlockVector, BlockData> blocks, ObjectMesh mesh, ObjectSummary summary) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* A slim slider floating on the right of the 3D view that hides an object's top layers, to look inside buildings
|
||||
* floor by floor. Most objects never need it, so it stays faint until the mouse comes near or a cut is active.
|
||||
*/
|
||||
final class ObjectLayerCut extends JPanel {
|
||||
private static final long serialVersionUID = 4473820211915526120L;
|
||||
private static final int PANEL_WIDTH = 30;
|
||||
private static final int MAX_HEIGHT = 320;
|
||||
private static final double HEIGHT_SHARE_OF_VIEW = 0.5D;
|
||||
private static final int EDGE_MARGIN = 8;
|
||||
private static final float FAINT_OPACITY = 0.28F;
|
||||
|
||||
private final JSlider slider = new JSlider(SwingConstants.VERTICAL, 1, 1, 1);
|
||||
private final JLabel shownLayers = new JLabel(" ", SwingConstants.CENTER);
|
||||
private final IntConsumer onLayersChanged;
|
||||
private boolean resetting;
|
||||
private boolean hovered;
|
||||
|
||||
/**
|
||||
* @param onLayersChanged told how many layers from the bottom stay visible whenever the user moves the slider
|
||||
*/
|
||||
ObjectLayerCut(IntConsumer onLayersChanged) {
|
||||
super(new BorderLayout(0, StudioTheme.scale(4)));
|
||||
this.onLayersChanged = onLayersChanged;
|
||||
setOpaque(false);
|
||||
setToolTipText("Hide the top layers to look inside · Page Up / Page Down");
|
||||
shownLayers.setFont(StudioTheme.smallFont());
|
||||
shownLayers.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
slider.setOpaque(false);
|
||||
slider.setFocusable(false);
|
||||
slider.setToolTipText(getToolTipText());
|
||||
slider.addChangeListener(event -> {
|
||||
showLayerLabel();
|
||||
if (!resetting) {
|
||||
onLayersChanged.accept(slider.getValue());
|
||||
}
|
||||
});
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent event) {
|
||||
setHovered(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
setHovered(slider.getValueIsAdjusting());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
setHovered(slider.contains(event.getPoint()));
|
||||
}
|
||||
};
|
||||
slider.addMouseListener(hover);
|
||||
addMouseListener(hover);
|
||||
add(shownLayers, BorderLayout.NORTH);
|
||||
add(slider, BorderLayout.CENTER);
|
||||
showHeight(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The view with this slider floating over its right edge, halfway down.
|
||||
*/
|
||||
JComponent floatingOver(JComponent view) {
|
||||
ObjectLayerCut cut = this;
|
||||
JPanel stack = new JPanel(null) {
|
||||
private static final long serialVersionUID = -1950381405658102347L;
|
||||
|
||||
// The slider overlaps the view, so the view may not repaint without the slider on top of it.
|
||||
@Override
|
||||
public boolean isOptimizedDrawingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
view.setBounds(0, 0, getWidth(), getHeight());
|
||||
int width = StudioTheme.scale(PANEL_WIDTH);
|
||||
int height = Math.min(StudioTheme.scale(MAX_HEIGHT), (int) (getHeight() * HEIGHT_SHARE_OF_VIEW));
|
||||
cut.setBounds(getWidth() - width - StudioTheme.scale(EDGE_MARGIN), (getHeight() - height) / 2, width, height);
|
||||
}
|
||||
};
|
||||
stack.add(cut);
|
||||
stack.add(view);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows every layer of a newly chosen object without telling anyone, since nothing is hidden yet.
|
||||
*/
|
||||
void showHeight(int layers) {
|
||||
resetting = true;
|
||||
try {
|
||||
slider.setMaximum(Math.max(1, layers));
|
||||
slider.setValue(slider.getMaximum());
|
||||
} finally {
|
||||
resetting = false;
|
||||
}
|
||||
showLayerLabel();
|
||||
setVisible(layers > 1);
|
||||
}
|
||||
|
||||
void stepBy(int layers) {
|
||||
if (isVisible()) {
|
||||
slider.setValue(slider.getValue() + layers);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isCutting() {
|
||||
return slider.getValue() < slider.getMaximum();
|
||||
}
|
||||
|
||||
int shownLayerCount() {
|
||||
return slider.getValue();
|
||||
}
|
||||
|
||||
int layerCount() {
|
||||
return slider.getMaximum();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintChildren(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
if (!hovered && !isCutting()) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, FAINT_OPACITY));
|
||||
}
|
||||
super.paintChildren(canvas);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void setHovered(boolean nowHovered) {
|
||||
if (hovered != nowHovered) {
|
||||
hovered = nowHovered;
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void showLayerLabel() {
|
||||
shownLayers.setText(isCutting() ? Integer.toString(slider.getValue()) : " ");
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* 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.BlockModel.Face;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.BlockVector;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
|
||||
* covers that side, which is most of them in a large object. See-through faces, like water and stained glass, are
|
||||
* kept apart because they are drawn last, blended over everything behind them. Top faces covered only by the block
|
||||
* above are kept aside too, so the top layers can be cut away without building the mesh again; what is cut away stays
|
||||
* as a faint ghost of the object's shape.
|
||||
*/
|
||||
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, List<Quad> cutAwayQuads, List<Quad> topsUnderBlockAbove,
|
||||
Bounds bounds) {
|
||||
private static final double SEE_THROUGH_OPACITY = 0.22D;
|
||||
private static final double FULL_OPACITY = 1D;
|
||||
private static final double CUT_AWAY_OPACITY = 0.1D;
|
||||
|
||||
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
|
||||
return build(blocks, modelOf, Bounds.of(blocks.keySet()), block -> false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bounds the whole object's extent, kept when only part of it is drawn so the view does not jump
|
||||
* @param isSeeThrough blocks drawn faintly, which hide nothing behind them, to look past them into the object
|
||||
*/
|
||||
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf, Bounds bounds,
|
||||
Predicate<BlockData> isSeeThrough) {
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
List<Quad> topsUnderBlockAbove = new ArrayList<>();
|
||||
BlockGrid grid = new BlockGrid(blocks, modelOf, bounds, isSeeThrough);
|
||||
for (BlockVector position : blocks.keySet()) {
|
||||
int x = position.getBlockX();
|
||||
int y = position.getBlockY();
|
||||
int z = position.getBlockZ();
|
||||
int cell = grid.cellAt(x, y, z);
|
||||
for (Face face : grid.modelIn(cell).faces()) {
|
||||
Quad quad = new Quad(face, x, y, z, grid.isSeeThroughIn(cell) ? SEE_THROUGH_OPACITY : FULL_OPACITY);
|
||||
if (!grid.isHiddenByNeighbour(x, y, z, face)) {
|
||||
(quad.isBlended() ? translucent : solid).add(quad);
|
||||
} else if (face.cullFace() == CubeFace.UP) {
|
||||
topsUnderBlockAbove.add(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), List.of(), List.copyOf(topsUnderBlockAbove), bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* This mesh with the blocks above the given layer faded to a ghost of the object's shape, and the tops of that
|
||||
* layer's blocks showing again. Quick enough to follow a slider as it moves.
|
||||
*/
|
||||
ObjectMesh cutAbove(int highestShownY) {
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
List<Quad> cutAway = new ArrayList<>();
|
||||
for (Quad quad : solidQuads) {
|
||||
keepOrCutAway(quad, highestShownY, solid, cutAway);
|
||||
}
|
||||
for (Quad quad : translucentQuads) {
|
||||
keepOrCutAway(quad, highestShownY, translucent, cutAway);
|
||||
}
|
||||
for (Quad quad : topsUnderBlockAbove) {
|
||||
if (quad.y() == highestShownY) {
|
||||
(quad.isBlended() ? translucent : solid).add(quad);
|
||||
}
|
||||
}
|
||||
return new ObjectMesh(solid, translucent, cutAway, List.of(), bounds);
|
||||
}
|
||||
|
||||
private static void keepOrCutAway(Quad quad, int highestShownY, List<Quad> kept, List<Quad> cutAway) {
|
||||
if (quad.y() <= highestShownY) {
|
||||
kept.add(quad);
|
||||
} else {
|
||||
cutAway.add(quad.faded(CUT_AWAY_OPACITY));
|
||||
}
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return solidQuads.isEmpty() && translucentQuads.isEmpty() && cutAwayQuads.isEmpty();
|
||||
}
|
||||
|
||||
int faceCount() {
|
||||
return solidQuads.size() + translucentQuads.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* The object's blocks laid out in an array over its bounds, so finding a block's neighbours is quick. Looking them
|
||||
* up by {@link BlockVector} in a map is slow, since its hash codes collide a lot.
|
||||
*/
|
||||
private static final class BlockGrid {
|
||||
private final Bounds bounds;
|
||||
private final BlockData[] blocks;
|
||||
private final BlockModel[] models;
|
||||
private final boolean[] seeThrough;
|
||||
|
||||
BlockGrid(Map<BlockVector, BlockData> placed, Function<BlockData, BlockModel> modelOf, Bounds bounds,
|
||||
Predicate<BlockData> isSeeThrough) {
|
||||
this.bounds = bounds;
|
||||
int cells = bounds.sizeX() * bounds.sizeY() * bounds.sizeZ();
|
||||
this.blocks = new BlockData[cells];
|
||||
this.models = new BlockModel[cells];
|
||||
this.seeThrough = new boolean[cells];
|
||||
placed.forEach((position, block) -> {
|
||||
int cell = cellAt(position.getBlockX(), position.getBlockY(), position.getBlockZ());
|
||||
blocks[cell] = block;
|
||||
models[cell] = modelOf.apply(block);
|
||||
seeThrough[cell] = isSeeThrough.test(block);
|
||||
});
|
||||
}
|
||||
|
||||
int cellAt(int x, int y, int z) {
|
||||
return ((y - bounds.minY()) * bounds.sizeZ() + (z - bounds.minZ())) * bounds.sizeX() + (x - bounds.minX());
|
||||
}
|
||||
|
||||
BlockModel modelIn(int cell) {
|
||||
return models[cell];
|
||||
}
|
||||
|
||||
boolean isSeeThroughIn(int cell) {
|
||||
return seeThrough[cell];
|
||||
}
|
||||
|
||||
// Glass next to glass and water next to water hide the side between them too, as they do in the game. A
|
||||
// see-through block only hides sides of other see-through blocks, so whatever it covers shows through it.
|
||||
boolean isHiddenByNeighbour(int x, int y, int z, Face face) {
|
||||
CubeFace side = face.cullFace();
|
||||
if (side == null || !contains(x + side.stepX(), y + side.stepY(), z + side.stepZ())) {
|
||||
return false;
|
||||
}
|
||||
int cell = cellAt(x, y, z);
|
||||
int neighbourCell = cellAt(x + side.stepX(), y + side.stepY(), z + side.stepZ());
|
||||
BlockData neighbour = blocks[neighbourCell];
|
||||
if (neighbour == null || (seeThrough[neighbourCell] && !seeThrough[cell])) {
|
||||
return false;
|
||||
}
|
||||
BlockModel neighbourModel = models[neighbourCell];
|
||||
if (neighbourModel.coveredSides().contains(side.opposite())) {
|
||||
return true;
|
||||
}
|
||||
return neighbour.getMaterial() == blocks[cell].getMaterial() && models[cell].hidesSameBlockSides()
|
||||
&& neighbourModel.hidesSameBlockSides();
|
||||
}
|
||||
|
||||
private boolean contains(int x, int y, int z) {
|
||||
return x >= bounds.minX() && x < bounds.maxX() && y >= bounds.minY() && y < bounds.maxY()
|
||||
&& z >= bounds.minZ() && z < bounds.maxZ();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A model face placed at a block in the object, drawn at the given opacity on top of its texture's own.
|
||||
*/
|
||||
record Quad(Face face, int x, int y, int z, double opacity) {
|
||||
boolean isBlended() {
|
||||
return opacity < FULL_OPACITY || face.texture().isTranslucent();
|
||||
}
|
||||
|
||||
Quad faded(double factor) {
|
||||
return new Quad(face, x, y, z, opacity * factor);
|
||||
}
|
||||
|
||||
double originX() {
|
||||
return x + face.origin().x();
|
||||
}
|
||||
|
||||
double originY() {
|
||||
return y + face.origin().y();
|
||||
}
|
||||
|
||||
double originZ() {
|
||||
return z + face.origin().z();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks' extent, with the maximum one past the last block so a single block spans one unit.
|
||||
*/
|
||||
record Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
private static final Bounds EMPTY = new Bounds(0, 0, 0, 1, 1, 1);
|
||||
|
||||
static Bounds of(Iterable<BlockVector> positions) {
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
int minZ = Integer.MAX_VALUE;
|
||||
int maxX = Integer.MIN_VALUE;
|
||||
int maxY = Integer.MIN_VALUE;
|
||||
int maxZ = Integer.MIN_VALUE;
|
||||
for (BlockVector position : positions) {
|
||||
minX = Math.min(minX, position.getBlockX());
|
||||
minY = Math.min(minY, position.getBlockY());
|
||||
minZ = Math.min(minZ, position.getBlockZ());
|
||||
maxX = Math.max(maxX, position.getBlockX() + 1);
|
||||
maxY = Math.max(maxY, position.getBlockY() + 1);
|
||||
maxZ = Math.max(maxZ, position.getBlockZ() + 1);
|
||||
}
|
||||
return minX == Integer.MAX_VALUE ? EMPTY : new Bounds(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
|
||||
int sizeX() {
|
||||
return maxX - minX;
|
||||
}
|
||||
|
||||
int sizeY() {
|
||||
return maxY - minY;
|
||||
}
|
||||
|
||||
int sizeZ() {
|
||||
return maxZ - minZ;
|
||||
}
|
||||
|
||||
int largestSize() {
|
||||
return Math.max(sizeX(), Math.max(sizeY(), sizeZ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* 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 javax.swing.BorderFactory;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Composite;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
|
||||
* Clicking a block makes it see-through in the 3D view, to look past walls into the object.
|
||||
*/
|
||||
final class ObjectOverviewPanel extends JPanel {
|
||||
private static final long serialVersionUID = -8811407613645718290L;
|
||||
private static final int PANEL_WIDTH = 280;
|
||||
private static final int ROW_HEIGHT = 26;
|
||||
private static final int ICON_SIZE = 16;
|
||||
private static final int ICON_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
private static final int SHARE_WIDTH = 92;
|
||||
private static final int SHARE_BAR_HEIGHT = 4;
|
||||
private static final float SEE_THROUGH_ICON_OPACITY = 0.3F;
|
||||
private static final String SEE_THROUGH_HINT = "Click a block to see through it";
|
||||
|
||||
private final JLabel title = new JLabel(" ");
|
||||
private final JLabel path = new JLabel(" ");
|
||||
private final JLabel size = statValue();
|
||||
private final JLabel blockCount = statValue();
|
||||
private final JLabel blockTypes = statValue();
|
||||
private final JLabel filled = statValue();
|
||||
private final JTextField filterField = new JTextField();
|
||||
private final DefaultListModel<ObjectSummary.BlockUse> rows = new DefaultListModel<>();
|
||||
private final JList<ObjectSummary.BlockUse> list = new JList<>(rows) {
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT);
|
||||
private final JButton showAllSolid = new JButton("Show all solid");
|
||||
private final Set<String> seeThroughTypes = new LinkedHashSet<>();
|
||||
private final Consumer<Set<String>> onSeeThroughChanged;
|
||||
private ObjectSummary summary;
|
||||
|
||||
/**
|
||||
* @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them
|
||||
*/
|
||||
ObjectOverviewPanel(Consumer<Set<String>> onSeeThroughChanged) {
|
||||
super(new BorderLayout());
|
||||
this.onSeeThroughChanged = onSeeThroughChanged;
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
add(buildHeader(), BorderLayout.NORTH);
|
||||
add(buildBlockList(), BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
void show(ObjectSummary nextSummary) {
|
||||
summary = nextSummary;
|
||||
title.setText(nextSummary.name());
|
||||
path.setText(nextSummary.key());
|
||||
path.setToolTipText(nextSummary.key());
|
||||
size.setText(nextSummary.sizeX() + " × " + nextSummary.sizeY() + " × " + nextSummary.sizeZ());
|
||||
blockCount.setText(formatCount(nextSummary.blockCount()));
|
||||
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
|
||||
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
|
||||
seeThroughTypes.clear();
|
||||
showSeeThroughState();
|
||||
refilter();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
summary = null;
|
||||
title.setText(" ");
|
||||
path.setText(" ");
|
||||
for (JLabel value : List.of(size, blockCount, blockTypes, filled)) {
|
||||
value.setText("—");
|
||||
}
|
||||
rows.clear();
|
||||
seeThroughTypes.clear();
|
||||
showSeeThroughState();
|
||||
}
|
||||
|
||||
private JPanel buildHeader() {
|
||||
JPanel header = new JPanel();
|
||||
header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS));
|
||||
header.setOpaque(false);
|
||||
header.setBorder(StudioTheme.emptyBorder(12, 16, 8, 16));
|
||||
title.setFont(StudioTheme.titleFont());
|
||||
path.setFont(StudioTheme.smallFont());
|
||||
path.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
header.add(leftAligned(title));
|
||||
header.add(leftAligned(path));
|
||||
|
||||
JPanel stats = new JPanel(new GridLayout(2, 2, StudioTheme.scale(12), StudioTheme.scale(8)));
|
||||
stats.setOpaque(false);
|
||||
stats.setBorder(StudioTheme.emptyBorder(12, 0, 4, 0));
|
||||
stats.add(stat("Size · X × Y × Z", size));
|
||||
stats.add(stat("Blocks", blockCount));
|
||||
stats.add(stat("Block types", blockTypes));
|
||||
stats.add(stat("Filled volume", filled));
|
||||
filled.setToolTipText("How much of the object's bounding box its blocks fill");
|
||||
header.add(leftAligned(stats));
|
||||
clear();
|
||||
return header;
|
||||
}
|
||||
|
||||
private JPanel buildBlockList() {
|
||||
JPanel blocks = new JPanel(new BorderLayout());
|
||||
blocks.setOpaque(false);
|
||||
StudioToolbar.makeSearchField(filterField, "Find a block...");
|
||||
filterField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
});
|
||||
list.setFocusable(false);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new BlockUseRenderer());
|
||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||
list.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
int row = list.locationToIndex(event.getPoint());
|
||||
if (SwingUtilities.isLeftMouseButton(event) && row >= 0 && list.getCellBounds(row, row).contains(event.getPoint())) {
|
||||
toggleSeeThrough(rows.get(row).blockType());
|
||||
}
|
||||
}
|
||||
});
|
||||
JPanel top = new JPanel();
|
||||
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
|
||||
top.setOpaque(false);
|
||||
top.add(leftAligned(StudioToolbar.padded(filterField, 4, 12, 4, 12)));
|
||||
top.add(leftAligned(StudioToolbar.padded(seeThroughRow(), 0, 14, 6, 12)));
|
||||
blocks.add(top, BorderLayout.NORTH);
|
||||
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private JPanel seeThroughRow() {
|
||||
seeThroughHint.setFont(StudioTheme.smallFont());
|
||||
seeThroughHint.setForeground(StudioTheme.MUTED_TEXT);
|
||||
showAllSolid.setFont(StudioTheme.smallFont());
|
||||
showAllSolid.setForeground(StudioTheme.LINK);
|
||||
showAllSolid.setBorderPainted(false);
|
||||
showAllSolid.setContentAreaFilled(false);
|
||||
showAllSolid.setFocusable(false);
|
||||
showAllSolid.setMargin(new Insets(0, 0, 0, 0));
|
||||
showAllSolid.setBorder(BorderFactory.createEmptyBorder());
|
||||
showAllSolid.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
showAllSolid.addActionListener(event -> {
|
||||
seeThroughTypes.clear();
|
||||
seeThroughChanged();
|
||||
});
|
||||
JPanel row = new JPanel(new BorderLayout());
|
||||
row.setOpaque(false);
|
||||
row.add(seeThroughHint, BorderLayout.CENTER);
|
||||
row.add(showAllSolid, BorderLayout.EAST);
|
||||
showSeeThroughState();
|
||||
return row;
|
||||
}
|
||||
|
||||
private void toggleSeeThrough(String blockType) {
|
||||
if (!seeThroughTypes.remove(blockType)) {
|
||||
seeThroughTypes.add(blockType);
|
||||
}
|
||||
seeThroughChanged();
|
||||
}
|
||||
|
||||
private void seeThroughChanged() {
|
||||
showSeeThroughState();
|
||||
list.repaint();
|
||||
onSeeThroughChanged.accept(Set.copyOf(seeThroughTypes));
|
||||
}
|
||||
|
||||
private void showSeeThroughState() {
|
||||
int count = seeThroughTypes.size();
|
||||
seeThroughHint.setText(count == 0 ? SEE_THROUGH_HINT : count == 1 ? "1 block see-through" : count + " blocks see-through");
|
||||
showAllSolid.setVisible(count > 0);
|
||||
}
|
||||
|
||||
private void refilter() {
|
||||
rows.clear();
|
||||
if (summary == null) {
|
||||
return;
|
||||
}
|
||||
String filter = filterField.getText().trim().toLowerCase(Locale.ROOT).replace(' ', '_');
|
||||
for (ObjectSummary.BlockUse use : summary.blockUses()) {
|
||||
if (use.blockType().contains(filter)) {
|
||||
rows.addElement(use);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JPanel stat(String label, JLabel value) {
|
||||
JPanel stat = new JPanel(new BorderLayout());
|
||||
stat.setOpaque(false);
|
||||
JLabel name = new JLabel(label);
|
||||
name.setFont(StudioTheme.smallFont());
|
||||
name.setForeground(StudioTheme.MUTED_TEXT);
|
||||
stat.add(name, BorderLayout.NORTH);
|
||||
stat.add(value, BorderLayout.CENTER);
|
||||
return stat;
|
||||
}
|
||||
|
||||
private static JLabel statValue() {
|
||||
JLabel value = new JLabel("—");
|
||||
value.setFont(StudioTheme.boldFont());
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Component leftAligned(JPanel panel) {
|
||||
panel.setAlignmentX(LEFT_ALIGNMENT);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static Component leftAligned(JLabel label) {
|
||||
label.setAlignmentX(LEFT_ALIGNMENT);
|
||||
return label;
|
||||
}
|
||||
|
||||
private static String formatCount(int count) {
|
||||
return String.format(Locale.ROOT, "%,d", count);
|
||||
}
|
||||
|
||||
private final class BlockUseRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 2785337045812613941L;
|
||||
private transient BufferedImage icon;
|
||||
private boolean seeThrough;
|
||||
private double share;
|
||||
private String percent = "";
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
|
||||
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
|
||||
seeThrough = seeThroughTypes.contains(use.blockType());
|
||||
setFont(StudioTheme.font());
|
||||
setForeground(seeThrough ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
|
||||
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks · click to "
|
||||
+ (seeThrough ? "make solid again" : "see through it"));
|
||||
icon = use.icon();
|
||||
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
|
||||
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
int iconSize = StudioTheme.scale(ICON_SIZE);
|
||||
int iconX = StudioTheme.scale(ROW_PADDING);
|
||||
int iconY = (getHeight() - iconSize) / 2;
|
||||
if (icon != null) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
|
||||
Composite solid = canvas.getComposite();
|
||||
if (seeThrough) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, SEE_THROUGH_ICON_OPACITY));
|
||||
}
|
||||
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
|
||||
canvas.setComposite(solid);
|
||||
} else {
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
|
||||
}
|
||||
drawShare(canvas);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawShare(Graphics2D canvas) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
int padding = StudioTheme.scale(ROW_PADDING);
|
||||
int percentWidth = canvas.getFontMetrics().stringWidth("100.0%");
|
||||
int barWidth = StudioTheme.scale(SHARE_WIDTH) - percentWidth - StudioTheme.scale(6);
|
||||
int barX = getWidth() - padding - StudioTheme.scale(SHARE_WIDTH);
|
||||
int barHeight = StudioTheme.scale(SHARE_BAR_HEIGHT);
|
||||
int barY = (getHeight() - barHeight) / 2;
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
|
||||
canvas.setColor(seeThrough ? StudioTheme.CONTROL_BORDER : StudioTheme.ACCENT);
|
||||
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(percent, getWidth() - padding - textWidth, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* 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.BlockModel.Face;
|
||||
import com.volmit.iris.core.gui.BlockModel.TextureMapping;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Draws an object mesh with an orthographic camera into a picture, without a graphics card: each quad is filled pixel
|
||||
* by pixel, and a depth buffer keeps the nearest one. A cut-away part's faint ghost only fills pixels the object
|
||||
* leaves empty, so it never hazes over what is shown. See-through quads come last, farthest first, each blended over
|
||||
* what is already drawn.
|
||||
*/
|
||||
final class ObjectRenderer {
|
||||
private static final Color GRID = new ThemeColor(0xE3E4E8, 0x34373C);
|
||||
private static final int GRID_LINES_PER_SIDE = 25;
|
||||
private static final double GRID_REACH = 0.7D;
|
||||
private static final double GRID_BELOW_OBJECT = 0.035D;
|
||||
// Shared quad edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
|
||||
private static final double EDGE_TOLERANCE = 1e-6D;
|
||||
private static final double EDGE_ON_DETERMINANT = 1e-9D;
|
||||
// A face's own texels end a hair inside its UV rectangle, so rounding never picks the texel next to it.
|
||||
private static final double TEXEL_EDGE_MARGIN = 1e-6D;
|
||||
private static final double FULL_ALPHA = 255D;
|
||||
// Zoom 2 fills the picture with the object's bounding box; thumbnails keep a hair of margin around it.
|
||||
private static final ObjectCamera THUMBNAIL_CAMERA = new ObjectCamera(ObjectCamera.START.yaw(), ObjectCamera.START.pitch(), 1.9D, 0D, 0D);
|
||||
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final int[] pixels;
|
||||
private final double[] depths;
|
||||
private final boolean[] showsObject;
|
||||
private final Projection projection;
|
||||
private final int gridColor = GRID.getRGB() & 0xFFFFFF;
|
||||
|
||||
private ObjectRenderer(BufferedImage image, Projection projection) {
|
||||
this.width = image.getWidth();
|
||||
this.height = image.getHeight();
|
||||
this.pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
this.depths = new double[pixels.length];
|
||||
this.showsObject = new boolean[pixels.length];
|
||||
this.projection = projection;
|
||||
Arrays.fill(pixels, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
|
||||
Arrays.fill(depths, Double.NEGATIVE_INFINITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param coveredLeftWidth how much of the picture's left side something floats over, so the object is centred and
|
||||
* fitted in the rest
|
||||
*/
|
||||
static BufferedImage render(ObjectMesh mesh, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
BufferedImage image = blankImage(width, height);
|
||||
int covered = Math.max(0, Math.min(image.getWidth() - 1, coveredLeftWidth));
|
||||
ObjectRenderer renderer = new ObjectRenderer(image, new Projection(mesh.bounds(), mesh.bounds(), camera, image.getWidth(), image.getHeight(), covered));
|
||||
renderer.drawGrid(mesh.bounds());
|
||||
renderer.drawMesh(mesh);
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws {@code mesh} from the starting angle, without the floor grid, at the scale that fits an object of
|
||||
* {@code sizeToFit}. Objects drawn with the same size compare at their true sizes, standing on the same ground.
|
||||
*/
|
||||
static BufferedImage renderThumbnail(ObjectMesh mesh, ObjectMesh.Bounds sizeToFit, int width, int height) {
|
||||
BufferedImage image = blankImage(width, height);
|
||||
new ObjectRenderer(image, new Projection(mesh.bounds(), sizeToFit, THUMBNAIL_CAMERA, image.getWidth(), image.getHeight(), 0)).drawMesh(mesh);
|
||||
return image;
|
||||
}
|
||||
|
||||
private static BufferedImage blankImage(int width, int height) {
|
||||
return new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
|
||||
private void drawMesh(ObjectMesh mesh) {
|
||||
for (ObjectMesh.Quad quad : mesh.solidQuads()) {
|
||||
drawQuad(quad, Pass.SOLID);
|
||||
}
|
||||
for (ObjectMesh.Quad quad : mesh.cutAwayQuads()) {
|
||||
drawQuad(quad, Pass.CUT_AWAY_GHOST);
|
||||
}
|
||||
for (ObjectMesh.Quad quad : farthestFirst(mesh.translucentQuads())) {
|
||||
drawQuad(quad, Pass.BLENDED);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ObjectMesh.Quad> farthestFirst(List<ObjectMesh.Quad> quads) {
|
||||
List<ObjectMesh.Quad> sorted = new ArrayList<>(quads);
|
||||
sorted.sort(Comparator.comparingDouble(this::centerDepth));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private double centerDepth(ObjectMesh.Quad quad) {
|
||||
Face face = quad.face();
|
||||
return projection.depth(
|
||||
quad.originX() + (face.edgeU().x() + face.edgeV().x()) / 2D,
|
||||
quad.originY() + (face.edgeU().y() + face.edgeV().y()) / 2D,
|
||||
quad.originZ() + (face.edgeU().z() + face.edgeV().z()) / 2D);
|
||||
}
|
||||
|
||||
private void drawGrid(ObjectMesh.Bounds bounds) {
|
||||
int reach = (int) Math.ceil(bounds.largestSize() * GRID_REACH);
|
||||
int step = Math.max(1, (int) Math.ceil(reach / (double) GRID_LINES_PER_SIDE));
|
||||
double floor = bounds.minY() - GRID_BELOW_OBJECT;
|
||||
int centerX = Math.floorDiv(bounds.minX() + bounds.maxX(), 2);
|
||||
int centerZ = Math.floorDiv(bounds.minZ() + bounds.maxZ(), 2);
|
||||
for (int offset = -reach; offset <= reach; offset += step) {
|
||||
drawLine(centerX + offset, floor, centerZ - reach, centerX + offset, floor, centerZ + reach);
|
||||
drawLine(centerX - reach, floor, centerZ + offset, centerX + reach, floor, centerZ + offset);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawLine(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) {
|
||||
double startX = projection.screenX(fromX, fromY, fromZ);
|
||||
double startY = projection.screenY(fromX, fromY, fromZ);
|
||||
double startDepth = projection.depth(fromX, fromY, fromZ);
|
||||
double endX = projection.screenX(toX, toY, toZ);
|
||||
double endY = projection.screenY(toX, toY, toZ);
|
||||
double endDepth = projection.depth(toX, toY, toZ);
|
||||
int steps = (int) Math.ceil(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)));
|
||||
for (int step = 0; step <= steps; step++) {
|
||||
double progress = steps == 0 ? 0D : step / (double) steps;
|
||||
int x = (int) (startX + (endX - startX) * progress);
|
||||
int y = (int) (startY + (endY - startY) * progress);
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) {
|
||||
continue;
|
||||
}
|
||||
int index = y * width + x;
|
||||
double depth = startDepth + (endDepth - startDepth) * progress;
|
||||
if (depth > depths[index]) {
|
||||
depths[index] = depth;
|
||||
pixels[index] = gridColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Later faces win ties, so an overlay drawn on the same box, like grass on a grass block's side, shows on top.
|
||||
private void drawQuad(ObjectMesh.Quad quad, Pass pass) {
|
||||
Face face = quad.face();
|
||||
if (!projection.facesCamera(face.normal().x(), face.normal().y(), face.normal().z())) {
|
||||
return;
|
||||
}
|
||||
Vec3 u = face.edgeU();
|
||||
Vec3 v = face.edgeV();
|
||||
double originX = projection.screenX(quad.originX(), quad.originY(), quad.originZ());
|
||||
double originY = projection.screenY(quad.originX(), quad.originY(), quad.originZ());
|
||||
double originDepth = projection.depth(quad.originX(), quad.originY(), quad.originZ());
|
||||
double edgeUX = projection.screenStepX(u.x(), u.z());
|
||||
double edgeUY = projection.screenStepY(u.x(), u.y(), u.z());
|
||||
double edgeUDepth = projection.depthStep(u.x(), u.y(), u.z());
|
||||
double edgeVX = projection.screenStepX(v.x(), v.z());
|
||||
double edgeVY = projection.screenStepY(v.x(), v.y(), v.z());
|
||||
double edgeVDepth = projection.depthStep(v.x(), v.y(), v.z());
|
||||
double determinant = edgeUX * edgeVY - edgeUY * edgeVX;
|
||||
if (Math.abs(determinant) < EDGE_ON_DETERMINANT) {
|
||||
return;
|
||||
}
|
||||
|
||||
int left = Math.max(0, (int) Math.floor(min4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
int right = Math.min(width - 1, (int) Math.ceil(max4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
int top = Math.max(0, (int) Math.floor(min4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
int bottom = Math.min(height - 1, (int) Math.ceil(max4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
if (left > right || top > bottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
BlockTexture texture = face.texture();
|
||||
TextureMapping mapping = face.mapping();
|
||||
TexelRange columns = TexelRange.of(mapping.u(), mapping.uAlongU(), mapping.uAlongV(), texture.width());
|
||||
TexelRange rows = TexelRange.of(mapping.v(), mapping.vAlongU(), mapping.vAlongV(), texture.height());
|
||||
int[] texels = texture.pixels();
|
||||
boolean[] opaque = texture.opaque();
|
||||
int[] alphas = texture.alphas();
|
||||
double shade = face.shade();
|
||||
double alongUPerPixelX = edgeVY / determinant;
|
||||
double alongVPerPixelX = -edgeUY / determinant;
|
||||
for (int y = top; y <= bottom; y++) {
|
||||
double offsetX = left + 0.5D - originX;
|
||||
double offsetY = y + 0.5D - originY;
|
||||
double alongU = (offsetX * edgeVY - offsetY * edgeVX) / determinant;
|
||||
double alongV = (offsetY * edgeUX - offsetX * edgeUY) / determinant;
|
||||
int rowStart = y * width;
|
||||
for (int x = left; x <= right; x++, alongU += alongUPerPixelX, alongV += alongVPerPixelX) {
|
||||
if (alongU < -EDGE_TOLERANCE || alongU > 1D + EDGE_TOLERANCE || alongV < -EDGE_TOLERANCE || alongV > 1D + EDGE_TOLERANCE) {
|
||||
continue;
|
||||
}
|
||||
int index = rowStart + x;
|
||||
double depth = originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
|
||||
if (depth < depths[index] || (pass == Pass.CUT_AWAY_GHOST && showsObject[index])) {
|
||||
continue;
|
||||
}
|
||||
int texelX = columns.texelAt(mapping.u() + alongU * mapping.uAlongU() + alongV * mapping.uAlongV());
|
||||
int texelY = rows.texelAt(mapping.v() + alongU * mapping.vAlongU() + alongV * mapping.vAlongV());
|
||||
int texel = texelY * texture.width() + texelX;
|
||||
if (!opaque[texel]) {
|
||||
continue;
|
||||
}
|
||||
int color = BlockPreviewColors.shade(texels[texel], shade);
|
||||
if (pass != Pass.SOLID) {
|
||||
pixels[index] = blend(color, pixels[index], (alphas == null ? 1D : alphas[texel] / FULL_ALPHA) * quad.opacity());
|
||||
continue;
|
||||
}
|
||||
depths[index] = depth;
|
||||
pixels[index] = color;
|
||||
showsObject[index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int blend(int over, int under, double coverage) {
|
||||
return blendChannel(over, under, coverage, 16) << 16 | blendChannel(over, under, coverage, 8) << 8 | blendChannel(over, under, coverage, 0);
|
||||
}
|
||||
|
||||
private static int blendChannel(int over, int under, double coverage, int shift) {
|
||||
return (int) Math.round(((over >>> shift) & 0xFF) * coverage + ((under >>> shift) & 0xFF) * (1D - coverage));
|
||||
}
|
||||
|
||||
private static double min4(double a, double b, double c, double d) {
|
||||
return Math.min(Math.min(a, b), Math.min(c, d));
|
||||
}
|
||||
|
||||
private static double max4(double a, double b, double c, double d) {
|
||||
return Math.max(Math.max(a, b), Math.max(c, d));
|
||||
}
|
||||
|
||||
/**
|
||||
* The texels a face covers along one texture axis, so a pixel on the face's edge never takes its neighbour's.
|
||||
*/
|
||||
private record TexelRange(int first, int last, int size) {
|
||||
static TexelRange of(double start, double alongU, double alongV, int size) {
|
||||
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
|
||||
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
|
||||
int first = clamp((int) Math.floor(low * size + TEXEL_EDGE_MARGIN), size);
|
||||
int last = Math.max(first, clamp((int) Math.ceil(high * size - TEXEL_EDGE_MARGIN) - 1, size));
|
||||
return new TexelRange(first, last, size);
|
||||
}
|
||||
|
||||
int texelAt(double fraction) {
|
||||
int texel = (int) Math.floor(fraction * size);
|
||||
return texel < first ? first : Math.min(texel, last);
|
||||
}
|
||||
|
||||
private static int clamp(int texel, int size) {
|
||||
return Math.max(0, Math.min(size - 1, texel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns block positions into pixels. Larger depth is nearer the camera.
|
||||
*/
|
||||
private static final class Projection {
|
||||
private final double centerX;
|
||||
private final double centerY;
|
||||
private final double centerZ;
|
||||
private final double cosYaw;
|
||||
private final double sinYaw;
|
||||
private final double cosPitch;
|
||||
private final double sinPitch;
|
||||
private final double pixelsPerBlock;
|
||||
private final double screenCenterX;
|
||||
private final double screenCenterY;
|
||||
|
||||
/**
|
||||
* @param shown the object drawn, centred across and standing where an object of {@code scaledAs} would
|
||||
* @param scaledAs the size that is fitted to the view
|
||||
*/
|
||||
private Projection(ObjectMesh.Bounds shown, ObjectMesh.Bounds scaledAs, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
this.centerX = (shown.minX() + shown.maxX()) / 2D;
|
||||
this.centerY = shown.minY() + scaledAs.sizeY() / 2D;
|
||||
this.centerZ = (shown.minZ() + shown.maxZ()) / 2D;
|
||||
this.cosYaw = Math.cos(camera.yaw());
|
||||
this.sinYaw = Math.sin(camera.yaw());
|
||||
this.cosPitch = Math.cos(camera.pitch());
|
||||
this.sinPitch = Math.sin(camera.pitch());
|
||||
int visibleWidth = width - coveredLeftWidth;
|
||||
double fit = fitAtStartingAngle(scaledAs, visibleWidth / (double) height);
|
||||
this.pixelsPerBlock = camera.zoom() * fit * height / 2D / scaledAs.largestSize();
|
||||
this.screenCenterX = coveredLeftWidth + visibleWidth / 2D + camera.panX() * visibleWidth / 2D;
|
||||
this.screenCenterY = height / 2D - camera.panY() * height / 2D;
|
||||
}
|
||||
|
||||
// How far to scale the object down so it fits the view at the starting angle; orbiting keeps the scale.
|
||||
private static double fitAtStartingAngle(ObjectMesh.Bounds bounds, double aspect) {
|
||||
double size = bounds.largestSize();
|
||||
double width = bounds.sizeX() / size;
|
||||
double height = bounds.sizeY() / size;
|
||||
double depth = bounds.sizeZ() / size;
|
||||
double yaw = ObjectCamera.START.yaw();
|
||||
double pitch = ObjectCamera.START.pitch();
|
||||
double projectedWidth = Math.abs(Math.cos(yaw)) * width + Math.abs(Math.sin(yaw)) * depth;
|
||||
double projectedDepth = Math.abs(Math.sin(yaw)) * width + Math.abs(Math.cos(yaw)) * depth;
|
||||
double projectedHeight = Math.abs(Math.cos(pitch)) * height + Math.abs(Math.sin(pitch)) * projectedDepth;
|
||||
return Math.min(1D, Math.min(aspect / projectedWidth, 1D / projectedHeight));
|
||||
}
|
||||
|
||||
double screenX(double x, double y, double z) {
|
||||
return screenCenterX + screenStepX(x - centerX, z - centerZ);
|
||||
}
|
||||
|
||||
double screenY(double x, double y, double z) {
|
||||
return screenCenterY + screenStepY(x - centerX, y - centerY, z - centerZ);
|
||||
}
|
||||
|
||||
double depth(double x, double y, double z) {
|
||||
return depthStep(x - centerX, y - centerY, z - centerZ);
|
||||
}
|
||||
|
||||
double screenStepX(double x, double z) {
|
||||
return (cosYaw * x - sinYaw * z) * pixelsPerBlock;
|
||||
}
|
||||
|
||||
double screenStepY(double x, double y, double z) {
|
||||
double towardCamera = sinYaw * x + cosYaw * z;
|
||||
return -(cosPitch * y - sinPitch * towardCamera) * pixelsPerBlock;
|
||||
}
|
||||
|
||||
double depthStep(double x, double y, double z) {
|
||||
double towardCamera = sinYaw * x + cosYaw * z;
|
||||
return sinPitch * y + cosPitch * towardCamera;
|
||||
}
|
||||
|
||||
boolean facesCamera(double normalX, double normalY, double normalZ) {
|
||||
return depthStep(normalX, normalY, normalZ) > 0D;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Pass {
|
||||
SOLID,
|
||||
CUT_AWAY_GHOST,
|
||||
BLENDED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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 javax.swing.DefaultComboBoxModel;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.UIManager;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.LongConsumer;
|
||||
|
||||
/**
|
||||
* Where the seed of a procedural object's variants is chosen: typed in, picked again from the seeds tried on this
|
||||
* object with the pack's own first, or rerolled at random. It keeps its size whatever it shows, so the bar it sits in
|
||||
* never shifts under the mouse.
|
||||
*/
|
||||
final class ObjectSeedChooser extends JPanel {
|
||||
private static final long serialVersionUID = 3127745038254406118L;
|
||||
private static final int FIELD_WIDTH = 120;
|
||||
private static final int TRIED_SEEDS_KEPT = 12;
|
||||
// Small enough to read back and type, and far more seeds than anyone tries.
|
||||
private static final int RANDOM_SEED_BOUND = 1_000_000;
|
||||
|
||||
private final JComboBox<Long> seeds = new JComboBox<>();
|
||||
private final LongConsumer onSeedChosen;
|
||||
private final List<Long> triedSeedsNewestFirst = new ArrayList<>();
|
||||
private OptionalLong packSeed = OptionalLong.empty();
|
||||
private long shownSeed;
|
||||
private boolean showingSeeds;
|
||||
|
||||
/**
|
||||
* @param onSeedChosen told the seed to bake the variants from whenever the user chooses another one
|
||||
*/
|
||||
ObjectSeedChooser(LongConsumer onSeedChosen) {
|
||||
super(new BorderLayout(StudioTheme.scale(4), 0));
|
||||
this.onSeedChosen = onSeedChosen;
|
||||
setOpaque(false);
|
||||
JLabel label = new JLabel("Seed");
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
seeds.setEditable(true);
|
||||
seeds.setFont(StudioTheme.smallFont());
|
||||
seeds.setToolTipText("Type a seed and press Enter, or pick one tried before");
|
||||
seeds.setRenderer(new PackSeedMarker());
|
||||
seeds.setPreferredSize(new Dimension(StudioTheme.scale(FIELD_WIDTH), seeds.getPreferredSize().height));
|
||||
seeds.addActionListener(event -> {
|
||||
if (!showingSeeds) {
|
||||
EventQueue.invokeLater(this::chooseEnteredSeed);
|
||||
}
|
||||
});
|
||||
add(label, BorderLayout.WEST);
|
||||
add(seeds, BorderLayout.CENTER);
|
||||
add(StudioToolbar.button("Reroll", "Bake the variants from a random seed", this::reroll), BorderLayout.EAST);
|
||||
}
|
||||
|
||||
/** Starts a new object's list of tried seeds. */
|
||||
void forgetTriedSeeds() {
|
||||
triedSeedsNewestFirst.clear();
|
||||
}
|
||||
|
||||
void showSeed(long shown, OptionalLong pack) {
|
||||
shownSeed = shown;
|
||||
packSeed = pack;
|
||||
if (!isPackSeed(shown)) {
|
||||
rememberTried(shown);
|
||||
}
|
||||
DefaultComboBoxModel<Long> choices = new DefaultComboBoxModel<>();
|
||||
pack.ifPresent(choices::addElement);
|
||||
triedSeedsNewestFirst.forEach(choices::addElement);
|
||||
showingSeeds = true;
|
||||
try {
|
||||
seeds.setModel(choices);
|
||||
seeds.setSelectedItem(shown);
|
||||
} finally {
|
||||
showingSeeds = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberTried(long seed) {
|
||||
triedSeedsNewestFirst.remove(seed);
|
||||
triedSeedsNewestFirst.add(0, seed);
|
||||
while (triedSeedsNewestFirst.size() > TRIED_SEEDS_KEPT) {
|
||||
triedSeedsNewestFirst.remove(triedSeedsNewestFirst.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// A pick from the list arrives as a seed, anything typed as text.
|
||||
private void chooseEnteredSeed() {
|
||||
OptionalLong entered = parse(seeds.getSelectedItem());
|
||||
if (entered.isEmpty()) {
|
||||
UIManager.getLookAndFeel().provideErrorFeedback(seeds);
|
||||
showSeed(shownSeed, packSeed);
|
||||
return;
|
||||
}
|
||||
if (entered.getAsLong() != shownSeed) {
|
||||
onSeedChosen.accept(entered.getAsLong());
|
||||
}
|
||||
}
|
||||
|
||||
private static OptionalLong parse(Object entered) {
|
||||
if (entered instanceof Long seed) {
|
||||
return OptionalLong.of(seed);
|
||||
}
|
||||
try {
|
||||
return OptionalLong.of(Long.parseLong(String.valueOf(entered).trim()));
|
||||
} catch (NumberFormatException notASeed) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void reroll() {
|
||||
long seed;
|
||||
do {
|
||||
seed = ThreadLocalRandom.current().nextInt(RANDOM_SEED_BOUND);
|
||||
} while (seed == shownSeed);
|
||||
onSeedChosen.accept(seed);
|
||||
}
|
||||
|
||||
private boolean isPackSeed(Object seed) {
|
||||
return packSeed.isPresent() && seed instanceof Long value && value == packSeed.getAsLong();
|
||||
}
|
||||
|
||||
private final class PackSeedMarker extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = -6061513307766393251L;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
|
||||
super.getListCellRendererComponent(list, value, index, selected, focused);
|
||||
if (index >= 0 && isPackSeed(value)) {
|
||||
setText(value + " (pack)");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.object.IrisObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Where an object explorer gets what it shows: the pack's object files, or the objects Iris generates from settings.
|
||||
* Both are called on the explorer's loader thread only.
|
||||
*/
|
||||
interface ObjectSource {
|
||||
/** What one entry is called, such as "object", for the browser's texts. */
|
||||
String noun();
|
||||
|
||||
/** What the view shows when the pack has no entries. */
|
||||
EmptyState emptyState();
|
||||
|
||||
/** The key of every entry, where a slash separates folders. */
|
||||
List<String> keys(IrisData data);
|
||||
|
||||
/** The objects of the entry, one for an object file and one per variant for a procedural object; empty when none load. */
|
||||
List<IrisObject> variants(IrisData data, String key);
|
||||
|
||||
/** The seed the pack bakes the entry's variants from; empty for anything not generated from a seed. */
|
||||
default OptionalLong seed(String key) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
/** The entry's variants as {@code seed} would bake them; only asked of entries that have a seed. */
|
||||
default List<IrisObject> variants(IrisData data, String key, long seed) {
|
||||
throw new UnsupportedOperationException(key + " is not generated from a seed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* What an object is made of: its size and how many of each block type it uses, most used first.
|
||||
*/
|
||||
record ObjectSummary(String key, int sizeX, int sizeY, int sizeZ, int blockCount, List<BlockUse> blockUses) {
|
||||
static ObjectSummary of(String key, ObjectMesh.Bounds bounds, Iterable<BlockData> blocks, Function<BlockData, BufferedImage> iconOf) {
|
||||
Map<String, Integer> counts = new HashMap<>();
|
||||
Map<String, BlockData> examples = new HashMap<>();
|
||||
int blockCount = 0;
|
||||
for (BlockData block : blocks) {
|
||||
String blockType = block.getMaterial().getKey().getKey();
|
||||
counts.merge(blockType, 1, Integer::sum);
|
||||
examples.putIfAbsent(blockType, block);
|
||||
blockCount++;
|
||||
}
|
||||
List<BlockUse> uses = counts.entrySet().stream()
|
||||
.map(count -> new BlockUse(count.getKey(), count.getValue(), iconOf.apply(examples.get(count.getKey()))))
|
||||
.sorted(Comparator.comparingInt(BlockUse::count).reversed().thenComparing(BlockUse::blockType))
|
||||
.toList();
|
||||
return new ObjectSummary(key, bounds.sizeX(), bounds.sizeY(), bounds.sizeZ(), blockCount, uses);
|
||||
}
|
||||
|
||||
String name() {
|
||||
return key.substring(key.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
double filledFraction() {
|
||||
long volume = (long) sizeX * sizeY * sizeZ;
|
||||
return volume == 0 ? 0D : blockCount / (double) volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param icon the block's texture, or null when Minecraft's textures are not available
|
||||
*/
|
||||
record BlockUse(String blockType, int count, BufferedImage icon) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* 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 javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.Scrollable;
|
||||
import javax.swing.Timer;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* Every variant of a procedural object side by side, drawn at one scale so their sizes compare. Clicking one opens it
|
||||
* in the 3D view. While new thumbnails are drawn, each card keeps its old one faded until its own arrives; cards
|
||||
* still waiting after a moment play the loading animation, one after another like a wave.
|
||||
*/
|
||||
final class ObjectVariantGallery extends JPanel {
|
||||
private static final long serialVersionUID = 6153024487310558262L;
|
||||
private static final int THUMBNAIL_SIZE = 220;
|
||||
private static final int CARD_PADDING = 6;
|
||||
private static final int CARD_GAP = 12;
|
||||
private static final int EDGE_MARGIN = 16;
|
||||
// The variant bar floats over the bottom edge, so the last row scrolls clear of it.
|
||||
private static final int VARIANT_BAR_CLEARANCE = 64;
|
||||
private static final float OUTDATED_THUMBNAIL_OPACITY = 0.35F;
|
||||
private static final int THUMBNAIL_SIZE_PER_LOADING_BLOCK = 14;
|
||||
private static final int LOADING_WAVE_STEP_MILLIS = 90;
|
||||
|
||||
private final JLabel title = new JLabel(" ");
|
||||
private final CardGrid grid = new CardGrid();
|
||||
private final IntConsumer onVariantOpened;
|
||||
private final Runnable onThemeChanged;
|
||||
private final List<VariantCard> cards = new ArrayList<>();
|
||||
private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaintWaitingCards());
|
||||
private int coveredLeftWidth;
|
||||
private int selectedIndex;
|
||||
private long waitingSinceMillis;
|
||||
|
||||
/**
|
||||
* @param onVariantOpened told the index of a variant the user clicks
|
||||
* @param onThemeChanged told when the theme changes, since thumbnails are drawn on the theme's background
|
||||
*/
|
||||
ObjectVariantGallery(IntConsumer onVariantOpened, Runnable onThemeChanged) {
|
||||
super(new BorderLayout(0, StudioTheme.scale(EDGE_MARGIN)));
|
||||
this.onVariantOpened = onVariantOpened;
|
||||
this.onThemeChanged = onThemeChanged;
|
||||
setOpaque(true);
|
||||
setBackground(StudioTheme.EDITOR_BACKGROUND);
|
||||
setFocusable(true);
|
||||
title.setFont(StudioTheme.sectionFont());
|
||||
title.setForeground(StudioTheme.TEXT);
|
||||
JScrollPane scroll = StudioToolbar.scroll(grid);
|
||||
scroll.setOpaque(false);
|
||||
scroll.getViewport().setOpaque(false);
|
||||
add(title, BorderLayout.NORTH);
|
||||
add(scroll, BorderLayout.CENTER);
|
||||
keepClearOfOverlays();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
if (onThemeChanged != null) {
|
||||
onThemeChanged.run();
|
||||
}
|
||||
}
|
||||
|
||||
void bindKey(int keyCode, String name, Runnable action) {
|
||||
getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(keyCode, 0), name);
|
||||
getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setCoveredLeftWidth(int width) {
|
||||
coveredLeftWidth = width;
|
||||
keepClearOfOverlays();
|
||||
revalidate();
|
||||
}
|
||||
|
||||
int thumbnailSize() {
|
||||
return StudioTheme.scale(THUMBNAIL_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for {@code count} variants, waiting for their thumbnails. The cards already shown stay, with their old
|
||||
* thumbnails faded, so a reroll never blanks the view.
|
||||
*/
|
||||
void showVariants(String heading, int count, int selected) {
|
||||
title.setText(heading);
|
||||
if (count != cards.size()) {
|
||||
replaceCards(count);
|
||||
}
|
||||
startWaiting();
|
||||
select(selected);
|
||||
}
|
||||
|
||||
/** Fades every thumbnail while something new loads. */
|
||||
void showLoading() {
|
||||
startWaiting();
|
||||
}
|
||||
|
||||
void showThumbnail(int index, BufferedImage thumbnail) {
|
||||
if (index < cards.size()) {
|
||||
cards.get(index).showThumbnail(thumbnail);
|
||||
}
|
||||
if (cards.stream().noneMatch(VariantCard::isWaiting)) {
|
||||
loadingTicker.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void replaceCards(int count) {
|
||||
cards.clear();
|
||||
grid.removeAll();
|
||||
for (int index = 0; index < count; index++) {
|
||||
VariantCard card = new VariantCard(index);
|
||||
cards.add(card);
|
||||
grid.add(card);
|
||||
}
|
||||
grid.revalidate();
|
||||
grid.repaint();
|
||||
}
|
||||
|
||||
private void startWaiting() {
|
||||
if (!loadingTicker.isRunning()) {
|
||||
waitingSinceMillis = System.currentTimeMillis();
|
||||
loadingTicker.start();
|
||||
}
|
||||
cards.forEach(VariantCard::markOutdated);
|
||||
}
|
||||
|
||||
private void repaintWaitingCards() {
|
||||
if (LoadingAnimation.isDue(waitingSinceMillis)) {
|
||||
cards.stream().filter(VariantCard::isWaiting).forEach(VariantCard::repaint);
|
||||
}
|
||||
}
|
||||
|
||||
void select(int index) {
|
||||
selectedIndex = index;
|
||||
cards.forEach(VariantCard::repaint);
|
||||
if (index < cards.size()) {
|
||||
VariantCard card = cards.get(index);
|
||||
grid.scrollRectToVisible(card.getBounds());
|
||||
}
|
||||
}
|
||||
|
||||
void showMessage(String text) {
|
||||
title.setText(text);
|
||||
loadingTicker.stop();
|
||||
cards.clear();
|
||||
grid.removeAll();
|
||||
grid.revalidate();
|
||||
grid.repaint();
|
||||
}
|
||||
|
||||
private void keepClearOfOverlays() {
|
||||
int margin = StudioTheme.scale(EDGE_MARGIN);
|
||||
setBorder(BorderFactory.createEmptyBorder(margin, coveredLeftWidth + margin, StudioTheme.scale(VARIANT_BAR_CLEARANCE), margin));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards in as many columns as fit the width, wrapping onto more rows.
|
||||
*/
|
||||
private final class CardGrid extends JPanel implements Scrollable {
|
||||
private static final long serialVersionUID = -3618705187215931408L;
|
||||
|
||||
CardGrid() {
|
||||
super(null);
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
Dimension card = cardSize();
|
||||
int gap = StudioTheme.scale(CARD_GAP);
|
||||
int columns = columns();
|
||||
for (int index = 0; index < getComponentCount(); index++) {
|
||||
int column = index % columns;
|
||||
int row = index / columns;
|
||||
getComponent(index).setBounds(column * (card.width + gap), row * (card.height + gap), card.width, card.height);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
Dimension card = cardSize();
|
||||
int gap = StudioTheme.scale(CARD_GAP);
|
||||
int rows = (getComponentCount() + columns() - 1) / columns();
|
||||
return new Dimension(card.width, Math.max(0, rows * (card.height + gap) - gap));
|
||||
}
|
||||
|
||||
private int columns() {
|
||||
int gap = StudioTheme.scale(CARD_GAP);
|
||||
return Math.max(1, (getWidth() + gap) / (cardSize().width + gap));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
|
||||
return cardSize().height / 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) {
|
||||
return visibleRect.height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Dimension cardSize() {
|
||||
int padding = StudioTheme.scale(CARD_PADDING);
|
||||
int labelHeight = getFontMetrics(StudioTheme.smallFont()).getHeight();
|
||||
return new Dimension(thumbnailSize() + 2 * padding, thumbnailSize() + labelHeight + 3 * padding);
|
||||
}
|
||||
|
||||
private final class VariantCard extends JComponent {
|
||||
private static final long serialVersionUID = 2296135546207834180L;
|
||||
|
||||
private final int index;
|
||||
private BufferedImage thumbnail;
|
||||
private boolean thumbnailOutdated;
|
||||
private boolean hovered;
|
||||
|
||||
VariantCard(int index) {
|
||||
this.index = index;
|
||||
setToolTipText("Open variant " + (index + 1) + " in 3D");
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
onVariantOpened.accept(VariantCard.this.index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent event) {
|
||||
setHovered(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
setHovered(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void showThumbnail(BufferedImage image) {
|
||||
thumbnail = image;
|
||||
thumbnailOutdated = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
void markOutdated() {
|
||||
thumbnailOutdated = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
boolean isWaiting() {
|
||||
return thumbnail == null || thumbnailOutdated;
|
||||
}
|
||||
|
||||
private void setHovered(boolean nowHovered) {
|
||||
hovered = nowHovered;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
double arc = StudioTheme.scale(StudioTheme.OVERLAY_ARC);
|
||||
canvas.setColor(index == selectedIndex ? StudioTheme.SELECTION : hovered ? StudioTheme.HOVER_BACKGROUND : StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fill(new RoundRectangle2D.Double(0, 0, getWidth(), getHeight(), arc, arc));
|
||||
int padding = StudioTheme.scale(CARD_PADDING);
|
||||
int size = thumbnailSize();
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
drawThumbnail(canvas, padding, size);
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
drawCentered(canvas, "Variant " + (index + 1), padding * 2 + size + metrics.getAscent());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawThumbnail(Graphics2D canvas, int padding, int size) {
|
||||
if (thumbnail != null) {
|
||||
Graphics2D faded = (Graphics2D) canvas.create();
|
||||
if (thumbnailOutdated) {
|
||||
faded.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OUTDATED_THUMBNAIL_OPACITY));
|
||||
}
|
||||
faded.drawImage(thumbnail, padding, padding, size, size, null);
|
||||
faded.dispose();
|
||||
}
|
||||
if (isWaiting() && LoadingAnimation.isDue(waitingSinceMillis)) {
|
||||
long shownMillis = System.currentTimeMillis() - waitingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS;
|
||||
LoadingAnimation.paint(canvas, padding + size / 2D, padding + size / 2D, size / (double) THUMBNAIL_SIZE_PER_LOADING_BLOCK,
|
||||
shownMillis - (long) index * LOADING_WAVE_STEP_MILLIS);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawCentered(Graphics2D canvas, String text, int baseline) {
|
||||
canvas.drawString(text, (getWidth() - canvas.getFontMetrics().stringWidth(text)) / 2, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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 javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.LongConsumer;
|
||||
|
||||
/**
|
||||
* A small bar floating at the bottom of the object view that steps through the variants of a procedural object and
|
||||
* the seeds they are baked from, and switches between one variant in 3D and all of them side by side. It hides itself
|
||||
* for anything with a single variant and no seed.
|
||||
*/
|
||||
final class ObjectVariantPicker extends JPanel {
|
||||
private static final long serialVersionUID = -2716301839488217455L;
|
||||
private static final int BOTTOM_MARGIN = 14;
|
||||
private static final int VARIANT_LABEL_WIDTH = 110;
|
||||
private static final int SECTION_GAP = 10;
|
||||
private static final String VARIANT_SHORTCUT_HINT = " · [ and ]";
|
||||
private static final String COMPARE_SHORTCUT_HINT = " · G";
|
||||
|
||||
private final JLabel shownVariant = label(VARIANT_LABEL_WIDTH);
|
||||
private final JComponent variantStepper;
|
||||
private final ObjectSeedChooser seedChooser;
|
||||
private final JButton compareButton;
|
||||
private final IntConsumer onVariantChosen;
|
||||
private int variantCount;
|
||||
private int shownIndex;
|
||||
private boolean seeded;
|
||||
private int coveredLeftWidth;
|
||||
|
||||
/**
|
||||
* @param onVariantChosen told the index of the variant to show whenever the user steps to another one
|
||||
* @param onSeedChosen told the seed to bake the variants from whenever the user chooses another one
|
||||
* @param onCompareToggled told when the user switches between one variant and all of them
|
||||
*/
|
||||
ObjectVariantPicker(IntConsumer onVariantChosen, LongConsumer onSeedChosen, Runnable onCompareToggled) {
|
||||
this.onVariantChosen = onVariantChosen;
|
||||
setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
|
||||
setOpaque(false);
|
||||
setBorder(StudioTheme.emptyBorder(3, 4, 3, 4));
|
||||
variantStepper = stepper(shownVariant,
|
||||
StudioToolbar.button("‹", "Previous variant" + VARIANT_SHORTCUT_HINT, () -> stepBy(-1)),
|
||||
StudioToolbar.button("›", "Next variant" + VARIANT_SHORTCUT_HINT, () -> stepBy(1)));
|
||||
seedChooser = new ObjectSeedChooser(onSeedChosen);
|
||||
compareButton = StudioToolbar.button("", "", onCompareToggled);
|
||||
add(variantStepper);
|
||||
add(Box.createHorizontalStrut(StudioTheme.scale(SECTION_GAP)));
|
||||
add(seedChooser);
|
||||
add(Box.createHorizontalStrut(StudioTheme.scale(SECTION_GAP)));
|
||||
add(compareButton);
|
||||
showComparing(false);
|
||||
showVariants(0, 0);
|
||||
}
|
||||
|
||||
private static JLabel label(int width) {
|
||||
JLabel label = new JLabel(" ", SwingConstants.CENTER);
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
label.setForeground(StudioTheme.TEXT);
|
||||
label.setPreferredSize(new Dimension(StudioTheme.scale(width), label.getPreferredSize().height));
|
||||
return label;
|
||||
}
|
||||
|
||||
private static JComponent stepper(JLabel shown, JButton previous, JButton next) {
|
||||
JPanel stepper = new JPanel(new BorderLayout());
|
||||
stepper.setOpaque(false);
|
||||
stepper.add(previous, BorderLayout.WEST);
|
||||
stepper.add(shown, BorderLayout.CENTER);
|
||||
stepper.add(next, BorderLayout.EAST);
|
||||
return stepper;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view with this bar floating over its bottom edge, centred on the part the left island leaves uncovered.
|
||||
*/
|
||||
JComponent floatingOver(JComponent view) {
|
||||
ObjectVariantPicker picker = this;
|
||||
JPanel stack = new JPanel(null) {
|
||||
private static final long serialVersionUID = 1609842937565014832L;
|
||||
|
||||
// The bar overlaps the view, so the view may not repaint without the bar on top of it.
|
||||
@Override
|
||||
public boolean isOptimizedDrawingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
view.setBounds(0, 0, getWidth(), getHeight());
|
||||
Dimension size = picker.getPreferredSize();
|
||||
int centerX = coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2;
|
||||
picker.setBounds(centerX - size.width / 2, getHeight() - size.height - StudioTheme.scale(BOTTOM_MARGIN), size.width, size.height);
|
||||
}
|
||||
};
|
||||
stack.add(picker);
|
||||
stack.add(view);
|
||||
return stack;
|
||||
}
|
||||
|
||||
void setCoveredLeftWidth(int width) {
|
||||
coveredLeftWidth = width;
|
||||
relayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows variant {@code index} of {@code count} without telling anyone, since the caller already shows it.
|
||||
*/
|
||||
void showVariants(int count, int index) {
|
||||
variantCount = count;
|
||||
shownIndex = index;
|
||||
shownVariant.setText("Variant " + (index + 1) + " of " + count);
|
||||
variantStepper.setVisible(count > 1);
|
||||
compareButton.setVisible(count > 1);
|
||||
showWhenUseful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the seed the variants are baked from; empty for objects not generated from a seed.
|
||||
*/
|
||||
void showSeed(OptionalLong shown, OptionalLong pack) {
|
||||
seeded = shown.isPresent();
|
||||
seedChooser.setVisible(seeded);
|
||||
shown.ifPresent(seed -> seedChooser.showSeed(seed, pack));
|
||||
showWhenUseful();
|
||||
}
|
||||
|
||||
void forgetTriedSeeds() {
|
||||
seedChooser.forgetTriedSeeds();
|
||||
}
|
||||
|
||||
void showComparing(boolean comparing) {
|
||||
compareButton.setText(comparing ? "3D view" : "All variants");
|
||||
compareButton.setToolTipText((comparing ? "Show the chosen variant in 3D" : "Show every variant side by side") + COMPARE_SHORTCUT_HINT);
|
||||
relayout();
|
||||
}
|
||||
|
||||
void stepBy(int step) {
|
||||
if (variantCount < 2) {
|
||||
return;
|
||||
}
|
||||
shownIndex = Math.floorMod(shownIndex + step, variantCount);
|
||||
showVariants(variantCount, shownIndex);
|
||||
onVariantChosen.accept(shownIndex);
|
||||
}
|
||||
|
||||
private void showWhenUseful() {
|
||||
setVisible(variantCount > 1 || seeded);
|
||||
relayout();
|
||||
}
|
||||
|
||||
private void relayout() {
|
||||
if (getParent() != null) {
|
||||
getParent().revalidate();
|
||||
getParent().repaint();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
double arc = StudioTheme.scale(StudioTheme.OVERLAY_ARC);
|
||||
RoundRectangle2D shape = new RoundRectangle2D.Double(0.5, 0.5, getWidth() - 1D, getHeight() - 1D, arc, arc);
|
||||
canvas.setColor(StudioTheme.OVERLAY_BACKGROUND);
|
||||
canvas.fill(shape);
|
||||
canvas.setColor(StudioTheme.OVERLAY_BORDER);
|
||||
canvas.draw(shape);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* 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 javax.swing.AbstractAction;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseWheelEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* The 3D view of one object. Drag to orbit, shift-drag or middle-drag to pan, scroll to zoom; arrow keys orbit and
|
||||
* plus and minus zoom too. Frames are drawn on a background thread, one at a time, always for the latest camera.
|
||||
* While the next object loads, the last picture stays up; only a slow load dims it under the loading animation.
|
||||
*/
|
||||
final class ObjectViewport extends JComponent {
|
||||
private static final long serialVersionUID = -3175226517409913524L;
|
||||
private static final double WHEEL_ZOOM_EXPONENT = 0.12D;
|
||||
private static final double KEY_ZOOM_FACTOR = 1.1D;
|
||||
private static final int KEY_ORBIT_PIXELS = 12;
|
||||
private static final float LOADING_DIM_OPACITY = 0.88F;
|
||||
private static final double LOADING_BLOCK_SIZE = 16D;
|
||||
private static final int LOADING_TEXT_GAP = 46;
|
||||
|
||||
private final ExecutorService renderThread = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Object Viewer");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private ObjectMesh mesh;
|
||||
private ObjectCamera camera = ObjectCamera.START;
|
||||
private BufferedImage frame;
|
||||
private String message = "Choose an object";
|
||||
private EmptyState emptyState;
|
||||
private boolean rendering;
|
||||
private boolean renderAgain;
|
||||
private Point dragPoint;
|
||||
private int coveredLeftWidth;
|
||||
private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaint());
|
||||
private String loadingText;
|
||||
private long loadingSinceMillis;
|
||||
|
||||
ObjectViewport() {
|
||||
setOpaque(true);
|
||||
setBackground(StudioTheme.EDITOR_BACKGROUND);
|
||||
setFocusable(true);
|
||||
installMouseControls();
|
||||
installKeyControls();
|
||||
addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent event) {
|
||||
requestFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The theme changed, and the last frame was drawn on the old theme's background.
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
// The last picture stays up until the first one of this mesh is drawn.
|
||||
void show(ObjectMesh nextMesh) {
|
||||
mesh = nextMesh;
|
||||
camera = ObjectCamera.START;
|
||||
message = nextMesh.isEmpty() ? "No visible blocks" : null;
|
||||
emptyState = null;
|
||||
if (nextMesh.isEmpty()) {
|
||||
frame = null;
|
||||
finishLoading();
|
||||
}
|
||||
requestFrame();
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the view as loading something new. A load already under way keeps its start, so stepping quickly through
|
||||
* objects still shows the animation once the wait adds up.
|
||||
*/
|
||||
void showLoading(String text) {
|
||||
if (loadingText == null) {
|
||||
loadingSinceMillis = System.currentTimeMillis();
|
||||
}
|
||||
loadingText = text;
|
||||
message = null;
|
||||
emptyState = null;
|
||||
loadingTicker.start();
|
||||
}
|
||||
|
||||
private void finishLoading() {
|
||||
loadingText = null;
|
||||
loadingTicker.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera.
|
||||
*/
|
||||
void replaceMesh(ObjectMesh nextMesh) {
|
||||
mesh = nextMesh;
|
||||
message = nextMesh.isEmpty() ? "No visible blocks" : null;
|
||||
emptyState = null;
|
||||
requestFrame();
|
||||
repaint();
|
||||
}
|
||||
|
||||
void bindKey(int keyCode, String name, Runnable action) {
|
||||
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
|
||||
getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void showMessage(String text) {
|
||||
mesh = null;
|
||||
message = text;
|
||||
emptyState = null;
|
||||
frame = null;
|
||||
finishLoading();
|
||||
repaint();
|
||||
}
|
||||
|
||||
void showEmptyState(EmptyState state) {
|
||||
mesh = null;
|
||||
message = null;
|
||||
emptyState = state;
|
||||
frame = null;
|
||||
finishLoading();
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the object clear of a panel floating over the viewport's left side.
|
||||
*/
|
||||
void setCoveredLeftWidth(int width) {
|
||||
if (width == coveredLeftWidth) {
|
||||
return;
|
||||
}
|
||||
coveredLeftWidth = width;
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
void resetCamera() {
|
||||
moveCamera(current -> ObjectCamera.START);
|
||||
}
|
||||
|
||||
void close() {
|
||||
loadingTicker.stop();
|
||||
renderThread.shutdownNow();
|
||||
}
|
||||
|
||||
private void moveCamera(UnaryOperator<ObjectCamera> move) {
|
||||
camera = move.apply(camera);
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
private void requestFrame() {
|
||||
if (mesh == null || mesh.isEmpty() || getWidth() < 1 || getHeight() < 1) {
|
||||
return;
|
||||
}
|
||||
if (rendering) {
|
||||
renderAgain = true;
|
||||
return;
|
||||
}
|
||||
rendering = true;
|
||||
ObjectMesh frameMesh = mesh;
|
||||
ObjectCamera frameCamera = camera;
|
||||
int width = getWidth();
|
||||
int height = getHeight();
|
||||
int covered = coveredLeftWidth;
|
||||
renderThread.execute(() -> {
|
||||
BufferedImage image = ObjectRenderer.render(frameMesh, frameCamera, width, height, covered);
|
||||
EventQueue.invokeLater(() -> finishFrame(frameMesh, image));
|
||||
});
|
||||
}
|
||||
|
||||
private void finishFrame(ObjectMesh frameMesh, BufferedImage image) {
|
||||
rendering = false;
|
||||
if (frameMesh == mesh) {
|
||||
frame = image;
|
||||
finishLoading();
|
||||
repaint();
|
||||
}
|
||||
if (renderAgain) {
|
||||
renderAgain = false;
|
||||
requestFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
graphics.setColor(getBackground());
|
||||
graphics.fillRect(0, 0, getWidth(), getHeight());
|
||||
if (frame != null) {
|
||||
graphics.drawImage(frame, 0, 0, null);
|
||||
}
|
||||
if (loadingText != null && LoadingAnimation.isDue(loadingSinceMillis)) {
|
||||
drawLoading((Graphics2D) graphics);
|
||||
}
|
||||
if (message != null) {
|
||||
drawMessage((Graphics2D) graphics);
|
||||
}
|
||||
if (emptyState != null) {
|
||||
emptyState.paint((Graphics2D) graphics, this, coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2, getHeight() / 2);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawLoading(Graphics2D graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
if (frame != null) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, LOADING_DIM_OPACITY));
|
||||
canvas.setColor(getBackground());
|
||||
canvas.fillRect(0, 0, getWidth(), getHeight());
|
||||
canvas.setComposite(AlphaComposite.SrcOver);
|
||||
}
|
||||
int centerX = coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2;
|
||||
int centerY = getHeight() / 2;
|
||||
long shownMillis = System.currentTimeMillis() - loadingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS;
|
||||
LoadingAnimation.paint(canvas, centerX, centerY, StudioTheme.scale(LOADING_BLOCK_SIZE), shownMillis);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
String text = loadingText + "...";
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(text);
|
||||
canvas.drawString(text, centerX - textWidth / 2, centerY + StudioTheme.scale(LOADING_TEXT_GAP));
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMessage(Graphics2D canvas) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(message);
|
||||
canvas.drawString(message, (getWidth() - textWidth) / 2, getHeight() / 2);
|
||||
}
|
||||
|
||||
private void installMouseControls() {
|
||||
MouseAdapter mouse = new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent event) {
|
||||
dragPoint = event.getPoint();
|
||||
requestFocusInWindow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
dragPoint = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent event) {
|
||||
if (dragPoint == null) {
|
||||
return;
|
||||
}
|
||||
int draggedX = event.getX() - dragPoint.x;
|
||||
int draggedY = event.getY() - dragPoint.y;
|
||||
dragPoint = event.getPoint();
|
||||
boolean panning = event.isShiftDown() || SwingUtilities.isMiddleMouseButton(event);
|
||||
moveCamera(current -> panning
|
||||
? current.pannedBy(2D * draggedX / Math.max(1, getWidth() - coveredLeftWidth), -2D * draggedY / getHeight())
|
||||
: current.orbitedBy(draggedX, draggedY));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseWheelMoved(MouseWheelEvent event) {
|
||||
moveCamera(current -> current.zoomedBy(Math.exp(-event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT)));
|
||||
}
|
||||
};
|
||||
addMouseListener(mouse);
|
||||
addMouseMotionListener(mouse);
|
||||
addMouseWheelListener(mouse);
|
||||
}
|
||||
|
||||
private void installKeyControls() {
|
||||
bindKey(KeyEvent.VK_LEFT, "orbit-left", current -> current.orbitedBy(-KEY_ORBIT_PIXELS, 0));
|
||||
bindKey(KeyEvent.VK_RIGHT, "orbit-right", current -> current.orbitedBy(KEY_ORBIT_PIXELS, 0));
|
||||
bindKey(KeyEvent.VK_UP, "orbit-up", current -> current.orbitedBy(0, KEY_ORBIT_PIXELS));
|
||||
bindKey(KeyEvent.VK_DOWN, "orbit-down", current -> current.orbitedBy(0, -KEY_ORBIT_PIXELS));
|
||||
bindKey(KeyEvent.VK_EQUALS, "zoom-in", current -> current.zoomedBy(KEY_ZOOM_FACTOR));
|
||||
bindKey(KeyEvent.VK_ADD, "zoom-in-keypad", current -> current.zoomedBy(KEY_ZOOM_FACTOR));
|
||||
bindKey(KeyEvent.VK_MINUS, "zoom-out", current -> current.zoomedBy(1D / KEY_ZOOM_FACTOR));
|
||||
bindKey(KeyEvent.VK_SUBTRACT, "zoom-out-keypad", current -> current.zoomedBy(1D / KEY_ZOOM_FACTOR));
|
||||
}
|
||||
|
||||
private void bindKey(int keyCode, String name, UnaryOperator<ObjectCamera> move) {
|
||||
bindKey(keyCode, name, () -> moveCamera(move));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.object.IrisObject;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The pack's object files, by their load keys.
|
||||
*/
|
||||
final class PackObjectSource implements ObjectSource {
|
||||
private static final int EMPTY_ICON_SIZE = 40;
|
||||
|
||||
@Override
|
||||
public String noun() {
|
||||
return "object";
|
||||
}
|
||||
|
||||
@Override
|
||||
public EmptyState emptyState() {
|
||||
return new EmptyState(MapViewIcon.large(MapView.OBJECTS, EMPTY_ICON_SIZE), "No objects yet",
|
||||
"Put the pack's .iob objects in", "objects/", "They appear here once the pack reloads.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> keys(IrisData data) {
|
||||
return Arrays.asList(data.getObjectLoader().getPossibleKeys());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IrisObject> variants(IrisData data, String key) {
|
||||
IrisObject object = data.getObjectLoader().load(key);
|
||||
return object == null ? List.of() : List.of(object);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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 com.google.gson.stream.JsonReader;
|
||||
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.IrisBlockData;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
record PaletteCatalog(List<RegionEntry> regions, List<String> biomesOutsideRegions, Map<String, BiomeEntry> biomes,
|
||||
List<SnippetEntry> snippets) {
|
||||
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 : regionKeysInUse(engine)) {
|
||||
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), loadSnippets(data, biomes));
|
||||
}
|
||||
|
||||
// Snippet files no biome refers to are listed too, so a new snippet shows up before it is put to use.
|
||||
private static List<SnippetEntry> loadSnippets(IrisData data, Map<String, BiomeEntry> biomes) {
|
||||
Map<String, SnippetEntry> snippets = new LinkedHashMap<>();
|
||||
for (BiomeEntry biome : biomes.values()) {
|
||||
for (PaletteSummary summary : biome.palettes()) {
|
||||
if (summary.snippet() == null) {
|
||||
continue;
|
||||
}
|
||||
BiomePalette.SnippetFolder folder = summary.palette().kind().snippetFolder();
|
||||
snippets.computeIfAbsent(folder.folder() + "/" + summary.snippet(),
|
||||
key -> new SnippetEntry(folder, summary.snippet(), summary.blocks(), new ArrayList<>()))
|
||||
.uses().add(summary.palette());
|
||||
}
|
||||
}
|
||||
for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) {
|
||||
for (String reference : data.getPossibleSnippets(folder.folder())) {
|
||||
String path = folder.pathOf(reference);
|
||||
snippets.putIfAbsent(folder.folder() + "/" + path, new SnippetEntry(folder, path, null, List.of()));
|
||||
}
|
||||
}
|
||||
return snippets.values().stream()
|
||||
.map(snippet -> new SnippetEntry(snippet.folder(), snippet.path(), snippet.blocks(), List.copyOf(snippet.uses())))
|
||||
.sorted(Comparator.comparing(SnippetEntry::folder).thenComparing(SnippetEntry::path))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// A studio focus region is generated even when the dimension's region list doesn't include it.
|
||||
private static Set<String> regionKeysInUse(Engine engine) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
if (engine.getDimension().hasFocusRegion()) {
|
||||
keys.add(engine.getDimension().getFocusRegion());
|
||||
}
|
||||
keys.addAll(engine.getDimension().getRegions());
|
||||
return keys;
|
||||
}
|
||||
|
||||
BiomeEntry biome(String key) {
|
||||
return key == null ? null : biomes.get(key);
|
||||
}
|
||||
|
||||
RegionEntry region(String key) {
|
||||
return regions.stream().filter(region -> region.key().equals(key)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
static String summarizeBlocks(KList<IrisBlockData> blocks) {
|
||||
List<String> names = blocks.stream()
|
||||
.map(block -> block.getBlock().replace("minecraft:", "").toLowerCase(Locale.ROOT))
|
||||
.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, IrisBiome> loaded = new LinkedHashMap<>();
|
||||
if (keys == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Arrays.sort(keys);
|
||||
for (String key : keys) {
|
||||
IrisBiome biome = data.getBiomeLoader().load(key);
|
||||
if (biome != null) {
|
||||
loaded.put(key, biome);
|
||||
}
|
||||
}
|
||||
Map<String, List<String>> parentsByChild = parentsByChild(loaded);
|
||||
Map<String, BiomeEntry> biomes = new LinkedHashMap<>();
|
||||
loaded.forEach((key, biome) -> biomes.put(key, new BiomeEntry(
|
||||
key,
|
||||
biome.getName(),
|
||||
summarizePalettes(key, biome),
|
||||
biome.getChildren().stream().filter(loaded::containsKey).distinct().toList(),
|
||||
parentsByChild.getOrDefault(key, List.of())
|
||||
)));
|
||||
return biomes;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> parentsByChild(Map<String, IrisBiome> biomes) {
|
||||
Map<String, List<String>> parents = new LinkedHashMap<>();
|
||||
biomes.forEach((parentKey, parent) -> parent.getChildren().stream().distinct().forEach(childKey ->
|
||||
parents.computeIfAbsent(childKey, key -> new ArrayList<>()).add(parentKey)));
|
||||
return parents;
|
||||
}
|
||||
|
||||
static List<PaletteSummary> summarizePalettes(String key, IrisBiome biome) {
|
||||
JsonObject biomeSource = readSource(biome.getLoadFile());
|
||||
return BiomePalette.allOf(key, biome).stream()
|
||||
.map(palette -> new PaletteSummary(palette, snippetName(biomeSource, palette),
|
||||
summarizeBlocks(palette.blocksIn(biome))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Snippets are copied into the biome when it loads, so only its file still tells which palettes came from one.
|
||||
private static String snippetName(JsonObject biomeSource, BiomePalette palette) {
|
||||
JsonElement element = biomeSource == null ? null : biomeSource.get(palette.kind().jsonKey());
|
||||
if (element instanceof JsonArray list) {
|
||||
element = palette.index() < list.size() ? list.get(palette.index()) : null;
|
||||
}
|
||||
if (element == null || !element.isJsonPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
return palette.kind().snippetFolder().pathOf(element.getAsString());
|
||||
}
|
||||
|
||||
private static JsonObject readSource(File file) {
|
||||
if (file == null || !file.isFile()) {
|
||||
return null;
|
||||
}
|
||||
try (JsonReader reader = new JsonReader(new FileReader(file))) {
|
||||
reader.setLenient(true);
|
||||
JsonElement source = JsonParser.parseReader(reader);
|
||||
return source.isJsonObject() ? source.getAsJsonObject() : null;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parentKeys the biomes that list this one as a child, which it can appear inside of
|
||||
*/
|
||||
record BiomeEntry(String key, String name, List<PaletteSummary> palettes, List<String> childKeys,
|
||||
List<String> parentKeys) {
|
||||
boolean matches(String filter) {
|
||||
if (key.toLowerCase().contains(filter) || name.toLowerCase().contains(filter)) {
|
||||
return true;
|
||||
}
|
||||
return palettes.stream().anyMatch(summary -> summary.text().toLowerCase().contains(filter));
|
||||
}
|
||||
|
||||
BiomePalette firstPalette() {
|
||||
return palettes.isEmpty() ? null : palettes.get(0).palette();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A snippet file palettes can come from.
|
||||
*
|
||||
* @param blocks a summary of its blocks, or null when no biome uses it
|
||||
* @param uses the palettes made from it, in biome order
|
||||
*/
|
||||
record SnippetEntry(BiomePalette.SnippetFolder folder, String path, String blocks, List<BiomePalette> uses) {
|
||||
boolean matches(String filter) {
|
||||
return path.toLowerCase(Locale.ROOT).contains(filter)
|
||||
|| (blocks != null && blocks.toLowerCase(Locale.ROOT).contains(filter));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param snippet the path of the snippet file the palette comes from, or null when the biome defines it itself
|
||||
*/
|
||||
record PaletteSummary(BiomePalette palette, String snippet, String blocks) {
|
||||
String text() {
|
||||
return snippet == null
|
||||
? palette.label() + " · " + blocks
|
||||
: palette.label() + " · " + snippet + " snippet · " + blocks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.engine.framework.Engine;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PaletteExplorerStart(Engine engine, UUID worldId, UUID playerId, String biomeKey, double centerX, double centerZ) {
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/*
|
||||
* 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 javax.swing.BorderFactory;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.RenderingHints;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
final class PaletteInspectorPanel extends JPanel {
|
||||
private static final long serialVersionUID = 4473102938518712001L;
|
||||
private static final int PANEL_WIDTH = 310;
|
||||
private static final Color HISTOGRAM_BAR = new Color(0x9D, 0xA0, 0xA8, 150);
|
||||
private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70);
|
||||
|
||||
private final Listener listener;
|
||||
private final JLabel titleLabel = new JLabel();
|
||||
private final JLabel detailLabel = new JLabel();
|
||||
private final JLabel depthLabel = new JLabel("", SwingConstants.CENTER);
|
||||
private final JButton shallowerButton = new JButton("‹");
|
||||
private final JButton deeperButton = new JButton("›");
|
||||
private final JPanel depthRow = new JPanel(new BorderLayout(StudioTheme.scale(6), 0));
|
||||
private final StatisticsView statisticsView = new StatisticsView();
|
||||
private PaletteLayerScale.View selectedView = PaletteLayerScale.View.BLOCKS;
|
||||
private int depth;
|
||||
private int deepestDepth;
|
||||
|
||||
PaletteInspectorPanel(Listener listener) {
|
||||
super(new BorderLayout());
|
||||
this.listener = listener;
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
add(buildControls(), BorderLayout.NORTH);
|
||||
add(StudioToolbar.scroll(statisticsView), BorderLayout.CENTER);
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
void showPreview(PaletteLayerPreview preview) {
|
||||
titleLabel.setText(preview.biomeKey() + " · " + preview.palette().lowercaseLabel());
|
||||
detailLabel.setText(placementText(preview));
|
||||
depth = preview.depth();
|
||||
deepestDepth = Math.max(0, preview.maxThickness() - 1);
|
||||
refreshDepthControls();
|
||||
statisticsView.show(preview);
|
||||
setVisible(true);
|
||||
}
|
||||
|
||||
void showTextureStatus(String status) {
|
||||
statisticsView.showTextureStatus(status);
|
||||
}
|
||||
|
||||
void hideInspector() {
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
PaletteLayerScale.View selectedView() {
|
||||
return selectedView;
|
||||
}
|
||||
|
||||
private JPanel buildControls() {
|
||||
JPanel controls = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(6)));
|
||||
controls.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(12, 14, 12, 14)
|
||||
));
|
||||
titleLabel.setFont(StudioTheme.titleFont());
|
||||
detailLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
detailLabel.setFont(StudioTheme.smallFont());
|
||||
controls.add(titleLabel);
|
||||
controls.add(detailLabel);
|
||||
|
||||
controls.add(sectionLabel("Show"));
|
||||
controls.add(buildViewButtons());
|
||||
controls.add(sectionLabel("Depth"));
|
||||
controls.add(buildDepthRow());
|
||||
return controls;
|
||||
}
|
||||
|
||||
private JPanel buildViewButtons() {
|
||||
JPanel row = new JPanel(new GridLayout(1, 0, StudioTheme.scale(4), 0));
|
||||
row.setOpaque(false);
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
for (PaletteLayerScale.View view : PaletteLayerScale.View.values()) {
|
||||
JToggleButton button = new JToggleButton(view.toString());
|
||||
button.setFocusable(false);
|
||||
button.setSelected(view == selectedView);
|
||||
button.addActionListener(event -> {
|
||||
selectedView = view;
|
||||
listener.onViewChanged(view);
|
||||
});
|
||||
group.add(button);
|
||||
row.add(button);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private JPanel buildDepthRow() {
|
||||
depthRow.setOpaque(false);
|
||||
shallowerButton.setToolTipText("Show the block one higher up");
|
||||
deeperButton.setToolTipText("Show the block one further down");
|
||||
shallowerButton.addActionListener(event -> changeDepth(-1));
|
||||
deeperButton.addActionListener(event -> changeDepth(1));
|
||||
depthRow.add(shallowerButton, BorderLayout.WEST);
|
||||
depthRow.add(depthLabel, BorderLayout.CENTER);
|
||||
depthRow.add(deeperButton, BorderLayout.EAST);
|
||||
return depthRow;
|
||||
}
|
||||
|
||||
private void changeDepth(int step) {
|
||||
int nextDepth = Math.clamp(depth + step, 0, deepestDepth);
|
||||
if (nextDepth == depth) {
|
||||
return;
|
||||
}
|
||||
depth = nextDepth;
|
||||
refreshDepthControls();
|
||||
listener.onDepthChanged(depth);
|
||||
}
|
||||
|
||||
private void refreshDepthControls() {
|
||||
depthLabel.setText(depthText(depth, deepestDepth));
|
||||
shallowerButton.setEnabled(depth > 0);
|
||||
deeperButton.setEnabled(depth < deepestDepth);
|
||||
}
|
||||
|
||||
private static JLabel sectionLabel(String text) {
|
||||
JLabel label = new JLabel(text.toUpperCase(Locale.ROOT));
|
||||
label.setForeground(StudioTheme.MUTED_TEXT);
|
||||
label.setFont(StudioTheme.sectionFont());
|
||||
return label;
|
||||
}
|
||||
|
||||
// Stacked layers divide their coordinates by the zoom twice before sampling; wall and slab blocks only once.
|
||||
private static String placementText(PaletteLayerPreview preview) {
|
||||
if (!preview.palette().kind().isStacked()) {
|
||||
return "Single blocks · zoom " + formatZoom(preview.zoom());
|
||||
}
|
||||
return thicknessText(preview) + " · zoom " + formatZoom(preview.zoom())
|
||||
+ " (×" + formatZoom(preview.zoom() * preview.zoom()) + " effective)";
|
||||
}
|
||||
|
||||
private static String thicknessText(PaletteLayerPreview preview) {
|
||||
if (preview.minThickness() == preview.maxThickness()) {
|
||||
return preview.maxThickness() + (preview.maxThickness() == 1 ? " block thick" : " blocks thick");
|
||||
}
|
||||
return preview.minThickness() + "–" + preview.maxThickness() + " blocks thick";
|
||||
}
|
||||
|
||||
private static String depthText(int depth, int deepestDepth) {
|
||||
if (deepestDepth == 0) {
|
||||
return "Top block only";
|
||||
}
|
||||
return depth == 0 ? "Top block" : depth + (depth == 1 ? " block down" : " blocks down");
|
||||
}
|
||||
|
||||
private static String formatZoom(double zoom) {
|
||||
return zoom == Math.rint(zoom) ? Long.toString((long) zoom) : String.format(Locale.ROOT, "%.2f", zoom);
|
||||
}
|
||||
|
||||
private static String percent(double share) {
|
||||
if (share > 0D && share < 0.001D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onViewChanged(PaletteLayerScale.View view);
|
||||
|
||||
void onDepthChanged(int depth);
|
||||
}
|
||||
|
||||
private static final class StatisticsView extends JComponent {
|
||||
private static final long serialVersionUID = -2318840125766150871L;
|
||||
private static final int MARGIN = 14;
|
||||
private static final int HISTOGRAM_HEIGHT = 84;
|
||||
private static final int BAND_HEIGHT = 12;
|
||||
private static final int SECTION_GAP = 18;
|
||||
private static final int TITLE_GAP = 6;
|
||||
private static final int NOTE_GAP = 4;
|
||||
private static final String WIDEST_WEIGHT = "100.0%";
|
||||
private static final String WIDEST_ACTUAL = "▲ 100.0%";
|
||||
|
||||
private PaletteLayerPreview preview;
|
||||
private String textureStatus;
|
||||
|
||||
private StatisticsView() {
|
||||
setOpaque(true);
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
}
|
||||
|
||||
private void show(PaletteLayerPreview nextPreview) {
|
||||
preview = nextPreview;
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void showTextureStatus(String status) {
|
||||
textureStatus = status;
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
int preferredWidth = StudioTheme.scale(PANEL_WIDTH - 20);
|
||||
if (preview == null) {
|
||||
return new Dimension(preferredWidth, 0);
|
||||
}
|
||||
int margin = StudioTheme.scale(MARGIN);
|
||||
int width = Math.max(getWidth(), preferredWidth) - margin * 2;
|
||||
int height = margin + valueSpreadHeight() + StudioTheme.scale(SECTION_GAP) + blockTableHeight()
|
||||
+ StudioTheme.scale(SECTION_GAP) + notesHeight(width) + margin;
|
||||
return new Dimension(preferredWidth, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fillRect(0, 0, getWidth(), getHeight());
|
||||
if (preview == null) {
|
||||
return;
|
||||
}
|
||||
int margin = StudioTheme.scale(MARGIN);
|
||||
int width = getWidth() - margin * 2;
|
||||
int top = margin;
|
||||
drawValueSpread(canvas, margin, top, width);
|
||||
top += valueSpreadHeight() + StudioTheme.scale(SECTION_GAP);
|
||||
drawBlockTable(canvas, margin, top, width);
|
||||
top += blockTableHeight() + StudioTheme.scale(SECTION_GAP);
|
||||
drawNotes(canvas, margin, top, width);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private int valueSpreadHeight() {
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + StudioTheme.scale(HISTOGRAM_HEIGHT)
|
||||
+ StudioTheme.scale(2) + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + smallMetrics().getHeight();
|
||||
}
|
||||
|
||||
private int blockTableHeight() {
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + rowHeight() * preview.entries().size();
|
||||
}
|
||||
|
||||
private int notesHeight(int width) {
|
||||
FontMetrics metrics = smallMetrics();
|
||||
int height = 0;
|
||||
for (String note : notes()) {
|
||||
height += wrap(note, metrics, width).size() * metrics.getHeight() + StudioTheme.scale(NOTE_GAP);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private int rowHeight() {
|
||||
return textMetrics().getHeight() + StudioTheme.scale(4);
|
||||
}
|
||||
|
||||
private FontMetrics textMetrics() {
|
||||
return getFontMetrics(StudioTheme.font());
|
||||
}
|
||||
|
||||
private FontMetrics smallMetrics() {
|
||||
return getFontMetrics(StudioTheme.smallFont());
|
||||
}
|
||||
|
||||
private FontMetrics sectionMetrics() {
|
||||
return getFontMetrics(StudioTheme.sectionFont());
|
||||
}
|
||||
|
||||
private void drawValueSpread(Graphics2D canvas, int x, int top, int width) {
|
||||
drawSectionTitle(canvas, "Noise values this layer produces", x, top + sectionMetrics().getAscent());
|
||||
int histogramTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT);
|
||||
int bandHeight = StudioTheme.scale(BAND_HEIGHT);
|
||||
long[] histogram = preview.histogram();
|
||||
long peak = 1L;
|
||||
for (long count : histogram) {
|
||||
peak = Math.max(peak, count);
|
||||
}
|
||||
canvas.setColor(HISTOGRAM_BAR);
|
||||
for (int bin = 0; bin < histogram.length; bin++) {
|
||||
int barLeft = x + (bin * width) / histogram.length;
|
||||
int barRight = x + ((bin + 1) * width) / histogram.length;
|
||||
int barHeight = (int) Math.round(histogramHeight * (histogram[bin] / (double) peak));
|
||||
canvas.fillRect(barLeft, histogramTop + histogramHeight - barHeight, Math.max(1, barRight - barLeft - 1), barHeight);
|
||||
}
|
||||
|
||||
int bandTop = histogramTop + histogramHeight + StudioTheme.scale(2);
|
||||
for (PaletteLayerPreview.Entry entry : preview.entries()) {
|
||||
if (Double.isNaN(entry.lowestNoise())) {
|
||||
continue;
|
||||
}
|
||||
int bandLeft = x + (int) Math.round(entry.lowestNoise() * width);
|
||||
int bandRight = x + (int) Math.round(entry.highestNoise() * width);
|
||||
canvas.setColor(new Color(entry.color()));
|
||||
canvas.fillRect(bandLeft, bandTop, Math.max(1, bandRight - bandLeft), bandHeight);
|
||||
if (entry.highestNoise() < 1D) {
|
||||
canvas.setColor(BAND_DIVIDER);
|
||||
canvas.drawLine(bandRight, histogramTop, bandRight, bandTop + bandHeight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int labelY = bandTop + bandHeight + StudioTheme.scale(3) + metrics.getAscent();
|
||||
canvas.drawString("0", x, labelY);
|
||||
canvas.drawString("0.5", x + (width - metrics.stringWidth("0.5")) / 2, labelY);
|
||||
canvas.drawString("1", x + width - metrics.stringWidth("1"), labelY);
|
||||
}
|
||||
|
||||
private void drawBlockTable(Graphics2D canvas, int x, int top, int width) {
|
||||
int columnGap = StudioTheme.scale(10);
|
||||
int actualRight = x + width;
|
||||
int weightRight = actualRight - textMetrics().stringWidth(WIDEST_ACTUAL) - columnGap;
|
||||
int nameRight = weightRight - textMetrics().stringWidth(WIDEST_WEIGHT) - columnGap;
|
||||
int titleBaseline = top + sectionMetrics().getAscent();
|
||||
drawSectionTitle(canvas, "Blocks", x, titleBaseline);
|
||||
drawRightAligned(canvas, "weight", weightRight, titleBaseline);
|
||||
drawRightAligned(canvas, "actual", actualRight, titleBaseline);
|
||||
|
||||
int rowHeight = rowHeight();
|
||||
FontMetrics text = textMetrics();
|
||||
int swatchSize = text.getAscent() - text.getDescent();
|
||||
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
for (PaletteLayerPreview.Entry entry : preview.entries()) {
|
||||
int baseline = rowTop + (rowHeight + text.getAscent() - text.getDescent()) / 2;
|
||||
drawSwatch(canvas, entry, x, rowTop + (rowHeight - swatchSize) / 2, swatchSize);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(entry.found() ? StudioTheme.TEXT : StudioTheme.ERROR);
|
||||
String name = entry.found() ? shortBlockName(entry.block()) : shortBlockName(entry.block()) + " (not found)";
|
||||
int nameLeft = x + swatchSize + StudioTheme.scale(7);
|
||||
canvas.drawString(fit(canvas, name, nameRight - nameLeft), nameLeft, baseline);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
drawRightAligned(canvas, percent(entry.weightShare()), weightRight, baseline);
|
||||
canvas.setColor(actualColor(entry));
|
||||
drawRightAligned(canvas, actualText(entry), actualRight, baseline);
|
||||
rowTop += rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawSwatch(Graphics2D canvas, PaletteLayerPreview.Entry entry, int x, int y, int size) {
|
||||
if (entry.texture() != null) {
|
||||
canvas.drawImage(entry.texture().toImage(), x, y, size, size, null);
|
||||
return;
|
||||
}
|
||||
canvas.setColor(new Color(entry.color()));
|
||||
int arc = StudioTheme.scale(3);
|
||||
canvas.fillRoundRect(x, y, size, size, arc, arc);
|
||||
}
|
||||
|
||||
private void drawNotes(Graphics2D canvas, int x, int top, int width) {
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int lineY = top + metrics.getAscent();
|
||||
for (String note : notes()) {
|
||||
for (String line : wrap(note, metrics, width)) {
|
||||
canvas.drawString(line, x, lineY);
|
||||
lineY += metrics.getHeight();
|
||||
}
|
||||
lineY += StudioTheme.scale(NOTE_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
PaletteLayerPreview.statisticsAreaBlocks()));
|
||||
if (preview.absentShare() > 0D) {
|
||||
notes.add(absentText(preview) + " That ground is dark on the map and left out of the shares.");
|
||||
}
|
||||
if (preview.outOfRangeShare() > 0D) {
|
||||
notes.add(percent(preview.outOfRangeShare()) + " of the noise values fall outside 0 to 1."
|
||||
+ " Those spots get the first block.");
|
||||
}
|
||||
if (preview.simplification() != null) {
|
||||
notes.add(preview.simplification());
|
||||
}
|
||||
if (preview.hasSlopeCondition()) {
|
||||
notes.add("This " + paletteNoun(preview) + " has a slope condition, which the preview ignores.");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
private static String absentText(PaletteLayerPreview preview) {
|
||||
if (isDecorator(preview)) {
|
||||
return "The decorator leaves " + percent(preview.absentShare()) + " of the ground bare.";
|
||||
}
|
||||
return "The layer doesn't reach this depth on " + percent(preview.absentShare()) + " of the ground.";
|
||||
}
|
||||
|
||||
private static String paletteNoun(PaletteLayerPreview preview) {
|
||||
return isDecorator(preview) ? "decorator" : "layer";
|
||||
}
|
||||
|
||||
private static boolean isDecorator(PaletteLayerPreview preview) {
|
||||
return preview.palette().kind() == BiomePalette.Kind.DECORATOR;
|
||||
}
|
||||
|
||||
private static String actualText(PaletteLayerPreview.Entry entry) {
|
||||
if (entry.isFarBelowWeight()) {
|
||||
return "▼ " + percent(entry.actualShare());
|
||||
}
|
||||
if (entry.isFarAboveWeight()) {
|
||||
return "▲ " + percent(entry.actualShare());
|
||||
}
|
||||
return percent(entry.actualShare());
|
||||
}
|
||||
|
||||
private static Color actualColor(PaletteLayerPreview.Entry entry) {
|
||||
return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? StudioTheme.WARNING : StudioTheme.TEXT;
|
||||
}
|
||||
|
||||
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) {
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(title.toUpperCase(Locale.ROOT), x, y);
|
||||
}
|
||||
|
||||
private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) {
|
||||
canvas.drawString(text, right - canvas.getFontMetrics().stringWidth(text), y);
|
||||
}
|
||||
|
||||
private static String shortBlockName(String block) {
|
||||
return block.startsWith("minecraft:") ? block.substring("minecraft:".length()) : block;
|
||||
}
|
||||
|
||||
private static String fit(Graphics2D canvas, String text, int maxWidth) {
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
if (metrics.stringWidth(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
String shortened = text;
|
||||
while (shortened.length() > 1 && metrics.stringWidth(shortened + "…") > maxWidth) {
|
||||
shortened = shortened.substring(0, shortened.length() - 1);
|
||||
}
|
||||
return shortened + "…";
|
||||
}
|
||||
|
||||
private static List<String> wrap(String text, FontMetrics metrics, int maxWidth) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
StringBuilder line = new StringBuilder();
|
||||
for (String word : text.split(" ")) {
|
||||
String candidate = line.isEmpty() ? word : line + " " + word;
|
||||
if (metrics.stringWidth(candidate) > maxWidth && !line.isEmpty()) {
|
||||
lines.add(line.toString());
|
||||
line = new StringBuilder(word);
|
||||
} else {
|
||||
line = new StringBuilder(candidate);
|
||||
}
|
||||
}
|
||||
if (!line.isEmpty()) {
|
||||
lines.add(line.toString());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/*
|
||||
* 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.BlockTextures.BlockTexture;
|
||||
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.IrisBlockData;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
final class PaletteLayerPreview {
|
||||
static final int HISTOGRAM_BINS = 50;
|
||||
static final int STATISTICS_GRID_SIZE = 64;
|
||||
static final double STATISTICS_SPACING_BLOCKS = 37D;
|
||||
private static final int MISSING_BLOCK_COLOR = 0x5A5F6B;
|
||||
private static final double FAR_FROM_WEIGHT_FACTOR = 2D;
|
||||
private static final int BLOCK_CACHE_SIZE = 1 << 16;
|
||||
private static final int NO_OVERHANG = -1;
|
||||
|
||||
private final BiomePalette palette;
|
||||
private final KList<IrisBlockData> blocks;
|
||||
private final BiomePalettePlacement placement;
|
||||
private final PaletteLayerPreview ground;
|
||||
private final boolean[] showsGroundByEntry;
|
||||
private final int depth;
|
||||
private final IrisData data;
|
||||
private final int[] entryBySlot;
|
||||
private final BiomeTintField biomeTints;
|
||||
private final BlockTexture[] textureByEntry;
|
||||
private final BlockTexture[] upperHalfByEntry;
|
||||
private final boolean hasTallPlants;
|
||||
private final BlockTexture[] legendTextureByEntry;
|
||||
private final int[] legendColorByEntry;
|
||||
private final List<Entry> entries;
|
||||
private final SampledBlock[] blockCache = new SampledBlock[BLOCK_CACHE_SIZE];
|
||||
private final long[] histogram;
|
||||
private final double absentShare;
|
||||
private final double outOfRangeShare;
|
||||
|
||||
private PaletteLayerPreview(BiomePalette palette, BiomePalettePlacement placement, PaletteLayerPreview ground, int depth,
|
||||
IrisData data, BlockTextures textures, BiomeTintField biomeTints) {
|
||||
this.palette = palette;
|
||||
this.blocks = placement.blocks();
|
||||
this.placement = placement;
|
||||
this.ground = ground;
|
||||
this.depth = depth;
|
||||
this.data = data;
|
||||
this.entryBySlot = IrisBiomePaletteLayer.paletteEntryPerSlot(blocks, data).stream().mapToInt(Integer::intValue).toArray();
|
||||
if (entryBySlot.length == 0) {
|
||||
throw new IllegalStateException("No block of " + palette.biomeKey() + " " + palette.lowercaseLabel() + " could be found");
|
||||
}
|
||||
this.biomeTints = biomeTints;
|
||||
this.showsGroundByEntry = resolveGroundShowingEntries();
|
||||
this.textureByEntry = resolveEntryTextures(textures);
|
||||
this.upperHalfByEntry = resolveUpperHalves(textures);
|
||||
this.hasTallPlants = Arrays.stream(upperHalfByEntry).anyMatch(Objects::nonNull);
|
||||
this.legendTextureByEntry = tintForLegend(legendTextures());
|
||||
this.legendColorByEntry = resolveLegendColors();
|
||||
|
||||
Statistics statistics = measure();
|
||||
this.histogram = statistics.histogram();
|
||||
this.absentShare = statistics.absentShare();
|
||||
this.outOfRangeShare = statistics.outOfRangeShare();
|
||||
this.entries = describeEntries(statistics.sharesByEntry());
|
||||
}
|
||||
|
||||
static PaletteLayerPreview load(Engine engine, BiomePalette palette, int depth, BlockTextures textures) {
|
||||
if (engine == null || engine.isClosed()) {
|
||||
throw new IllegalStateException("Open a studio world to preview pack palettes");
|
||||
}
|
||||
IrisData data = engine.getData();
|
||||
IrisBiome biome = data.getBiomeLoader().load(palette.biomeKey());
|
||||
if (biome == null || palette.blocksIn(biome) == null) {
|
||||
throw new IllegalStateException("Palette removed during hotload: " + palette.biomeKey() + " " + palette.lowercaseLabel());
|
||||
}
|
||||
BiomePalettePlacement placement = BiomePalettePlacement.of(engine, biome, palette);
|
||||
BiomeTintField biomeTints = textures == null
|
||||
? null
|
||||
: new BiomeTintField(biome, new RNG(engine.getSeedManager().getBiome()), textures.biomeColors());
|
||||
PaletteLayerPreview ground = palette.kind() == BiomePalette.Kind.DECORATOR
|
||||
? groundUnder(engine, palette.biomeKey(), biome, data, textures, biomeTints)
|
||||
: null;
|
||||
return new PaletteLayerPreview(palette, placement, ground, depth, data, textures, biomeTints);
|
||||
}
|
||||
|
||||
// Decorations stand on the biome's top surface layer, so that is what shows around and between them.
|
||||
private static PaletteLayerPreview groundUnder(Engine engine, String biomeKey, IrisBiome biome, IrisData data,
|
||||
BlockTextures textures, BiomeTintField biomeTints) {
|
||||
BiomePalette topLayer = new BiomePalette(biomeKey, BiomePalette.Kind.LAYER, 0);
|
||||
KList<IrisBlockData> blocks = topLayer.blocksIn(biome);
|
||||
if (blocks == null || blocks.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new PaletteLayerPreview(topLayer, BiomePalettePlacement.of(engine, biome, topLayer), null, 0, data, textures,
|
||||
biomeTints);
|
||||
}
|
||||
|
||||
// The world picks one block per whole coordinate, and zoomed in, many pixels fall on the same block.
|
||||
double sample(double x, double z) {
|
||||
int blockX = (int) Math.floor(x);
|
||||
int blockZ = (int) Math.floor(z);
|
||||
int cacheSlot = blockCacheSlot(blockX, blockZ);
|
||||
SampledBlock cached = blockCache[cacheSlot];
|
||||
if (cached != null && cached.x() == blockX && cached.z() == blockZ) {
|
||||
return cached.noise();
|
||||
}
|
||||
double noise = sampleBlock(blockX, blockZ);
|
||||
blockCache[cacheSlot] = new SampledBlock(blockX, blockZ, noise);
|
||||
return noise;
|
||||
}
|
||||
|
||||
private double sampleBlock(int blockX, int blockZ) {
|
||||
if (placement.thickness(blockX, blockZ) <= depth) {
|
||||
return Double.NaN;
|
||||
}
|
||||
return placement.noise(blockX, blockZ, depth);
|
||||
}
|
||||
|
||||
private static int blockCacheSlot(int blockX, int blockZ) {
|
||||
long hash = blockX * 0x9E3779B97F4A7C15L ^ blockZ * 0xC2B2AE3D27D4EB4FL;
|
||||
return (int) ((hash ^ (hash >>> 29)) & (BLOCK_CACHE_SIZE - 1));
|
||||
}
|
||||
|
||||
int colorForNoise(double noise) {
|
||||
return legendColorByEntry[entryIndexForNoise(noise)];
|
||||
}
|
||||
|
||||
int blockColorAt(double noise, double worldX, double worldZ, boolean showTexturePixels) {
|
||||
int overhang = showTexturePixels ? overhangColorAt(worldX, worldZ) : NO_OVERHANG;
|
||||
if (overhang != NO_OVERHANG) {
|
||||
return overhang;
|
||||
}
|
||||
int entry = entryIndexForNoise(noise);
|
||||
if (showsGroundByEntry[entry]) {
|
||||
return bareColorAt(worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
BlockTexture texture = textureByEntry[entry];
|
||||
if (texture == null) {
|
||||
return legendColorByEntry[entry];
|
||||
}
|
||||
if (ground != null && showTexturePixels && !texture.isOpaqueAt(worldX, worldZ)) {
|
||||
return groundColorAt(worldX, worldZ, true);
|
||||
}
|
||||
int untintedColor = showTexturePixels ? texture.texelAt(worldX, worldZ) : texture.averageColor();
|
||||
return tinted(texture, untintedColor, worldX, worldZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour where the palette places nothing: the ground under a decorator, or plain dark otherwise.
|
||||
*/
|
||||
int bareColorAt(double worldX, double worldZ, boolean showTexturePixels) {
|
||||
if (ground == null) {
|
||||
return PaletteLayerScale.ABSENT_COLOR;
|
||||
}
|
||||
int overhang = showTexturePixels ? overhangColorAt(worldX, worldZ) : NO_OVERHANG;
|
||||
return overhang != NO_OVERHANG ? overhang : groundColorAt(worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
|
||||
// A two block tall plant stands up on the map: its top half covers the block above it, which is one block north.
|
||||
private int overhangColorAt(double worldX, double worldZ) {
|
||||
if (!hasTallPlants) {
|
||||
return NO_OVERHANG;
|
||||
}
|
||||
double plantZ = worldZ + 1D;
|
||||
double noise = sample(worldX, plantZ);
|
||||
if (Double.isNaN(noise)) {
|
||||
return NO_OVERHANG;
|
||||
}
|
||||
BlockTexture upperHalf = upperHalfByEntry[entryIndexForNoise(noise)];
|
||||
if (upperHalf == null || !upperHalf.isOpaqueAt(worldX, worldZ)) {
|
||||
return NO_OVERHANG;
|
||||
}
|
||||
return tinted(upperHalf, upperHalf.texelAt(worldX, worldZ), worldX, plantZ);
|
||||
}
|
||||
|
||||
private int tinted(BlockTexture texture, int untintedColor, double worldX, double worldZ) {
|
||||
if (texture.biomeTint() == null) {
|
||||
return untintedColor;
|
||||
}
|
||||
int tint = biomeTints.colorAt(texture.biomeTint(), (int) Math.floor(worldX), (int) Math.floor(worldZ));
|
||||
return BlockTexture.multiply(untintedColor, tint);
|
||||
}
|
||||
|
||||
private int groundColorAt(double worldX, double worldZ, boolean showTexturePixels) {
|
||||
double noise = ground.sample(worldX, worldZ);
|
||||
return Double.isNaN(noise) ? PaletteLayerScale.ABSENT_COLOR : ground.blockColorAt(noise, worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
|
||||
// A decorator places air to leave gaps, which looks the same as placing nothing.
|
||||
private boolean[] resolveGroundShowingEntries() {
|
||||
boolean[] showsGround = new boolean[blocks.size()];
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
showsGround[entry] = ground != null && block != null && block.getMaterial().isAir();
|
||||
}
|
||||
return showsGround;
|
||||
}
|
||||
|
||||
Entry entryForNoise(double noise) {
|
||||
return entries.get(entryIndexForNoise(noise));
|
||||
}
|
||||
|
||||
String biomeKey() {
|
||||
return palette.biomeKey();
|
||||
}
|
||||
|
||||
BiomePalette palette() {
|
||||
return palette;
|
||||
}
|
||||
|
||||
String simplification() {
|
||||
return placement.simplification();
|
||||
}
|
||||
|
||||
int depth() {
|
||||
return depth;
|
||||
}
|
||||
|
||||
int minThickness() {
|
||||
return placement.minThickness();
|
||||
}
|
||||
|
||||
int maxThickness() {
|
||||
return placement.maxThickness();
|
||||
}
|
||||
|
||||
double zoom() {
|
||||
return placement.zoom();
|
||||
}
|
||||
|
||||
boolean hasSlopeCondition() {
|
||||
return placement.hasSlopeCondition();
|
||||
}
|
||||
|
||||
List<Entry> entries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
long[] histogram() {
|
||||
return histogram.clone();
|
||||
}
|
||||
|
||||
double absentShare() {
|
||||
return absentShare;
|
||||
}
|
||||
|
||||
double outOfRangeShare() {
|
||||
return outOfRangeShare;
|
||||
}
|
||||
|
||||
static double statisticsAreaBlocks() {
|
||||
return STATISTICS_GRID_SIZE * STATISTICS_SPACING_BLOCKS;
|
||||
}
|
||||
|
||||
private int entryIndexForNoise(double noise) {
|
||||
return entryBySlot[IrisBiomePaletteLayer.slotForNoise(noise, entryBySlot.length)];
|
||||
}
|
||||
|
||||
private BlockTexture[] resolveEntryTextures(BlockTextures textures) {
|
||||
BlockTexture[] resolved = new BlockTexture[blocks.size()];
|
||||
if (textures == null) {
|
||||
return resolved;
|
||||
}
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
resolved[entry] = block == null ? null : textureOf(block, textures).orElse(null);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private BlockTexture[] resolveUpperHalves(BlockTextures textures) {
|
||||
BlockTexture[] resolved = new BlockTexture[blocks.size()];
|
||||
if (textures == null || palette.kind() != BiomePalette.Kind.DECORATOR) {
|
||||
return resolved;
|
||||
}
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
resolved[entry] = block == null ? null : textures.upperHalfIcon(block).orElse(null);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Tall plants are known by their top half, the way Minecraft draws them in the inventory.
|
||||
private BlockTexture[] legendTextures() {
|
||||
BlockTexture[] legend = textureByEntry.clone();
|
||||
for (int entry = 0; entry < legend.length; entry++) {
|
||||
if (upperHalfByEntry[entry] != null) {
|
||||
legend[entry] = upperHalfByEntry[entry];
|
||||
}
|
||||
}
|
||||
return legend;
|
||||
}
|
||||
|
||||
private Optional<BlockTexture> textureOf(BlockData block, BlockTextures textures) {
|
||||
return palette.kind() == BiomePalette.Kind.DECORATOR ? textures.iconTexture(block) : textures.topTexture(block);
|
||||
}
|
||||
|
||||
// The legend shows tinted blocks as they look at the world origin, where the statistics are sampled too.
|
||||
private BlockTexture[] tintForLegend(BlockTexture[] textures) {
|
||||
BlockTexture[] tinted = new BlockTexture[textures.length];
|
||||
for (int entry = 0; entry < textures.length; entry++) {
|
||||
BlockTexture texture = textures[entry];
|
||||
tinted[entry] = texture == null || texture.biomeTint() == null
|
||||
? texture
|
||||
: texture.tinted(biomeTints.colorAt(texture.biomeTint(), 0, 0));
|
||||
}
|
||||
return tinted;
|
||||
}
|
||||
|
||||
// Blocks without a texture, like custom blocks or everything before the textures load, fall back to map colours.
|
||||
private int[] resolveLegendColors() {
|
||||
List<BlockData> untexturedBlocks = new ArrayList<>();
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
if (block != null && textureByEntry[entry] == null) {
|
||||
untexturedBlocks.add(block);
|
||||
}
|
||||
}
|
||||
Map<String, Integer> mapColorsByBlock = BlockPreviewColors.colorsByBlock(untexturedBlocks);
|
||||
int[] colors = new int[blocks.size()];
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
if (block == null) {
|
||||
colors[entry] = MISSING_BLOCK_COLOR;
|
||||
} else if (legendTextureByEntry[entry] != null) {
|
||||
colors[entry] = legendTextureByEntry[entry].averageColor();
|
||||
} else {
|
||||
colors[entry] = mapColorsByBlock.get(block.getAsString(true));
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private Statistics measure() {
|
||||
long[] values = new long[HISTOGRAM_BINS];
|
||||
long[] samplesByEntry = new long[blocks.size()];
|
||||
long present = 0;
|
||||
long absent = 0;
|
||||
long outOfRange = 0;
|
||||
double origin = -statisticsAreaBlocks() / 2D;
|
||||
for (int gridX = 0; gridX < STATISTICS_GRID_SIZE; gridX++) {
|
||||
for (int gridZ = 0; gridZ < STATISTICS_GRID_SIZE; gridZ++) {
|
||||
double noise = sample(origin + gridX * STATISTICS_SPACING_BLOCKS, origin + gridZ * STATISTICS_SPACING_BLOCKS);
|
||||
if (Double.isNaN(noise)) {
|
||||
absent++;
|
||||
continue;
|
||||
}
|
||||
present++;
|
||||
if (noise < 0D || noise > 1D) {
|
||||
outOfRange++;
|
||||
}
|
||||
values[Math.clamp((int) (noise * HISTOGRAM_BINS), 0, HISTOGRAM_BINS - 1)]++;
|
||||
samplesByEntry[entryIndexForNoise(noise)]++;
|
||||
}
|
||||
}
|
||||
double[] sharesByEntry = new double[samplesByEntry.length];
|
||||
for (int entry = 0; entry < samplesByEntry.length; entry++) {
|
||||
sharesByEntry[entry] = present == 0 ? 0D : samplesByEntry[entry] / (double) present;
|
||||
}
|
||||
long total = present + absent;
|
||||
return new Statistics(values, sharesByEntry, absent / (double) total, present == 0 ? 0D : outOfRange / (double) present);
|
||||
}
|
||||
|
||||
private List<Entry> describeEntries(double[] sharesByEntry) {
|
||||
int slotCount = entryBySlot.length;
|
||||
List<Entry> described = new ArrayList<>(blocks.size());
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
IrisBlockData block = blocks.get(entry);
|
||||
int firstSlot = firstSlotOf(entry);
|
||||
int lastSlot = lastSlotOf(entry);
|
||||
boolean placed = firstSlot >= 0;
|
||||
described.add(new Entry(
|
||||
block.getBlock(),
|
||||
block.getWeight(),
|
||||
block.getBlockData(data) != null,
|
||||
legendColorByEntry[entry],
|
||||
legendTextureByEntry[entry],
|
||||
placed ? (lastSlot - firstSlot + 1) / (double) slotCount : 0D,
|
||||
sharesByEntry[entry],
|
||||
placed ? slotLowerBound(firstSlot, slotCount) : Double.NaN,
|
||||
placed ? slotUpperBound(lastSlot, slotCount) : Double.NaN
|
||||
));
|
||||
}
|
||||
return List.copyOf(described);
|
||||
}
|
||||
|
||||
private int firstSlotOf(int entry) {
|
||||
for (int slot = 0; slot < entryBySlot.length; slot++) {
|
||||
if (entryBySlot[slot] == entry) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int lastSlotOf(int entry) {
|
||||
for (int slot = entryBySlot.length - 1; slot >= 0; slot--) {
|
||||
if (entryBySlot[slot] == entry) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Slots are picked by rounding, so the first and last slot only own half a step of the value range.
|
||||
private static double slotLowerBound(int slot, int slotCount) {
|
||||
return slot == 0 ? 0D : (slot - 0.5D) / (slotCount - 1);
|
||||
}
|
||||
|
||||
private static double slotUpperBound(int slot, int slotCount) {
|
||||
return slot == slotCount - 1 ? 1D : (slot + 0.5D) / (slotCount - 1);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
boolean isFarAboveWeight() {
|
||||
return found && actualShare > weightShare * FAR_FROM_WEIGHT_FACTOR;
|
||||
}
|
||||
}
|
||||
|
||||
private record SampledBlock(int x, int z, double noise) {
|
||||
}
|
||||
|
||||
private record Statistics(long[] histogram, double[] sharesByEntry, double absentShare, double outOfRangeShare) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 java.awt.Color;
|
||||
|
||||
final class PaletteLayerScale implements NoiseColorScale {
|
||||
static final int ABSENT_COLOR = 0x161A24;
|
||||
private static final double OVERLAY_DARKEST_SHADE = 0.3D;
|
||||
|
||||
private final View view;
|
||||
private final PaletteLayerPreview preview;
|
||||
private final NoisePalette noisePalette;
|
||||
private final boolean showTexturePixels;
|
||||
|
||||
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) {
|
||||
if (view == View.NOISE) {
|
||||
return noisePalette.colorFinite(value);
|
||||
}
|
||||
int blockColor = preview.blockColorAt(value, worldX, worldZ, showTexturePixels);
|
||||
return view == View.BLOCKS ? blockColor : BlockPreviewColors.shade(blockColor, overlayShade(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorFinite(double value) {
|
||||
return switch (view) {
|
||||
case BLOCKS -> preview.colorForNoise(value);
|
||||
case NOISE -> noisePalette.colorFinite(value);
|
||||
case BLOCKS_SHADED_BY_NOISE -> BlockPreviewColors.shade(preview.colorForNoise(value), overlayShade(value));
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalid() {
|
||||
return ABSENT_COLOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalidAt(double worldX, double worldZ) {
|
||||
return view == View.NOISE ? ABSENT_COLOR : preview.bareColorAt(worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Color displayColorNormalized(double normalized) {
|
||||
return new Color(colorFinite(minimum() + normalized * (maximum() - minimum())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return view == View.NOISE ? noisePalette.label() : view.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double minimum() {
|
||||
return view == View.NOISE ? noisePalette.minimum() : 0D;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double maximum() {
|
||||
return view == View.NOISE ? noisePalette.maximum() : 1D;
|
||||
}
|
||||
|
||||
private static double overlayShade(double value) {
|
||||
return OVERLAY_DARKEST_SHADE + (1D - OVERLAY_DARKEST_SHADE) * Math.clamp(value, 0D, 1D);
|
||||
}
|
||||
|
||||
enum View {
|
||||
BLOCKS("Blocks"),
|
||||
NOISE("Noise"),
|
||||
BLOCKS_SHADED_BY_NOISE("Both");
|
||||
|
||||
private final String label;
|
||||
|
||||
View(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* 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.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.ListSelectionModel;
|
||||
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;
|
||||
|
||||
|
||||
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 int MESSAGE_WRAP_WIDTH_PIXELS = 190;
|
||||
|
||||
private final Listener listener;
|
||||
private final CardLayout cards = new CardLayout();
|
||||
private final JPanel cardPanel = new JPanel(cards);
|
||||
private final JLabel regionLabel = new JLabel();
|
||||
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 BiomePalette selectedPalette;
|
||||
private boolean updatingSelection;
|
||||
private boolean untracked;
|
||||
|
||||
PaletteNavigator(Listener listener) {
|
||||
super(new BorderLayout());
|
||||
this.listener = listener;
|
||||
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) {
|
||||
location = nextLocation;
|
||||
refreshHere();
|
||||
}
|
||||
|
||||
// Without a player there is nowhere to call "here", so the Here tab explains how to get one instead.
|
||||
void showUntracked() {
|
||||
untracked = true;
|
||||
refreshHere();
|
||||
}
|
||||
|
||||
void markSelected(BiomePalette palette) {
|
||||
selectedPalette = palette;
|
||||
updatingSelection = true;
|
||||
try {
|
||||
selectInHereList();
|
||||
selectInTree();
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private JPanel buildTabBar() {
|
||||
JPanel bar = new JPanel(new GridLayout(1, 2, 0, 0));
|
||||
bar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR));
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
JToggleButton here = createTab("Here", HERE_CARD, group);
|
||||
JToggleButton overview = createTab("Overview", OVERVIEW_CARD, group);
|
||||
bar.add(here);
|
||||
bar.add(overview);
|
||||
here.doClick();
|
||||
return bar;
|
||||
}
|
||||
|
||||
private JToggleButton createTab(String text, String card, ButtonGroup group) {
|
||||
JToggleButton tab = StudioToolbar.tab(text);
|
||||
tab.addActionListener(event -> cards.show(cardPanel, card));
|
||||
group.add(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
private JPanel buildHereCard() {
|
||||
JPanel card = new JPanel(new BorderLayout());
|
||||
JPanel location = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(3)));
|
||||
location.setBorder(StudioTheme.emptyBorder(9, 12, 5, 12));
|
||||
location.add(regionLabel);
|
||||
|
||||
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) {
|
||||
choose(row.biomeKey(), row.palette());
|
||||
}
|
||||
});
|
||||
card.add(location, BorderLayout.NORTH);
|
||||
card.add(StudioToolbar.scroll(hereList), BorderLayout.CENTER);
|
||||
return card;
|
||||
}
|
||||
|
||||
private JPanel buildOverviewCard() {
|
||||
JPanel card = new JPanel(new BorderLayout());
|
||||
StudioToolbar.makeSearchField(searchField, "Search biomes, snippets or blocks...");
|
||||
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.setRowHeight(StudioTheme.scale(24));
|
||||
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) {
|
||||
choose(node.biomeKey(), node.palette());
|
||||
}
|
||||
});
|
||||
card.add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||
card.add(StudioToolbar.scroll(overviewTree), BorderLayout.CENTER);
|
||||
return card;
|
||||
}
|
||||
|
||||
// Choosing a biome rather than one of its palettes opens its first palette.
|
||||
private void choose(String biomeKey, BiomePalette palette) {
|
||||
BiomeEntry biome = catalog == null ? null : catalog.biome(biomeKey);
|
||||
BiomePalette chosen = palette != null ? palette : biome == null ? null : biome.firstPalette();
|
||||
if (chosen != null) {
|
||||
listener.onPaletteChosen(chosen);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshHere() {
|
||||
updatingSelection = true;
|
||||
try {
|
||||
hereRows.clear();
|
||||
if (untracked) {
|
||||
showHereMessage("Run /iris std pal in-game to see the palettes where you stand.");
|
||||
return;
|
||||
}
|
||||
if (location == null || catalog == null) {
|
||||
showHereMessage("Finding where you are...");
|
||||
return;
|
||||
}
|
||||
switch (location.status()) {
|
||||
case OFFLINE -> showHereMessage("You are offline.");
|
||||
case IN_OTHER_WORLD -> showHereMessage("You left the world this window shows.");
|
||||
case IN_REGION -> showHereRows(location);
|
||||
}
|
||||
selectInHereList();
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void showHereMessage(String message) {
|
||||
regionLabel.setText("<html><body style='width:" + StudioTheme.scale(MESSAGE_WRAP_WIDTH_PIXELS) + "px'>" + message + "</body></html>");
|
||||
}
|
||||
|
||||
private void showHereRows(PlayerLocation here) {
|
||||
RegionEntry region = catalog.region(here.regionKey());
|
||||
regionLabel.setText(labeled("Region", region == null ? here.regionKey() : region.name(), here.regionKey()));
|
||||
addBiomePalettes("Surface biome here", here.surfaceBiomeKey());
|
||||
if (here.isBelowSurfaceBiome()) {
|
||||
addBiomePalettes("Underground biome at your height", here.biomeAtPositionKey());
|
||||
}
|
||||
if (region == null) {
|
||||
return;
|
||||
}
|
||||
hereRows.addElement(HereRow.header("Biomes in this region"));
|
||||
for (String biomeKey : region.allBiomeKeys()) {
|
||||
addRegionBiome(biomeKey, 0, new HashSet<>(), here);
|
||||
}
|
||||
}
|
||||
|
||||
private void addBiomePalettes(String title, String biomeKey) {
|
||||
BiomeEntry biome = catalog.biome(biomeKey);
|
||||
if (biome == null) {
|
||||
return;
|
||||
}
|
||||
hereRows.addElement(HereRow.header(title));
|
||||
hereRows.addElement(HereRow.biome(biomeText(biome, childOfText(biome)), biomeKey, 0));
|
||||
addPaletteRows(biome, 0);
|
||||
}
|
||||
|
||||
private void addPaletteRows(BiomeEntry biome, int nesting) {
|
||||
for (PaletteCatalog.PaletteSummary summary : biome.palettes()) {
|
||||
hereRows.addElement(HereRow.palette(summary.text(), summary.palette(), nesting));
|
||||
}
|
||||
}
|
||||
|
||||
// Children sit under the biome they grow inside of, so a child biome is found where it actually appears.
|
||||
private void addRegionBiome(String biomeKey, int nesting, Set<String> ancestors, PlayerLocation here) {
|
||||
BiomeEntry biome = catalog.biome(biomeKey);
|
||||
if (biome == null || !ancestors.add(biomeKey)) {
|
||||
return;
|
||||
}
|
||||
boolean standingHere = biomeKey.equals(here.surfaceBiomeKey()) || biomeKey.equals(here.biomeAtPositionKey());
|
||||
hereRows.addElement(HereRow.biome(biomeText(biome, standingHere ? "you are here" : null), biomeKey, nesting));
|
||||
addPaletteRows(biome, nesting);
|
||||
for (String childKey : biome.childKeys()) {
|
||||
addRegionBiome(childKey, nesting + 1, new HashSet<>(ancestors), here);
|
||||
}
|
||||
}
|
||||
|
||||
private String childOfText(BiomeEntry biome) {
|
||||
if (biome.parentKeys().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> parentNames = biome.parentKeys().stream()
|
||||
.map(key -> catalog.biome(key) == null ? key : catalog.biome(key).name())
|
||||
.toList();
|
||||
return "child of " + String.join(", ", parentNames);
|
||||
}
|
||||
|
||||
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, null));
|
||||
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, null));
|
||||
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, null));
|
||||
root.add(outside);
|
||||
}
|
||||
addSnippetNodes(root, filter);
|
||||
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, null));
|
||||
for (PaletteCatalog.PaletteSummary summary : biome.palettes()) {
|
||||
biomeNode.add(new DefaultMutableTreeNode(new OverviewNode(summary.text(), null, biomeKey, summary.palette())));
|
||||
}
|
||||
for (String childKey : biome.childKeys()) {
|
||||
addBiomeNode(biomeNode, childKey, filter, new HashSet<>(ancestors));
|
||||
}
|
||||
boolean hasMatchingChild = biomeNode.getChildCount() > biome.palettes().size();
|
||||
if (filter.isEmpty() || biome.matches(filter) || hasMatchingChild) {
|
||||
parent.add(biomeNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets sit below the regions, so a palette picked elsewhere is still found in its biome first.
|
||||
private void addSnippetNodes(DefaultMutableTreeNode root, String filter) {
|
||||
for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) {
|
||||
DefaultMutableTreeNode folderNode = new DefaultMutableTreeNode();
|
||||
catalog.snippets().stream()
|
||||
.filter(snippet -> snippet.folder() == folder && (filter.isEmpty() || snippet.matches(filter)))
|
||||
.forEach(snippet -> folderNode.add(snippetNode(snippet)));
|
||||
if (folderNode.getChildCount() > 0) {
|
||||
folderNode.setUserObject(new OverviewNode(folder.label() + " snippets", folderNode.getChildCount() + " files", null, null));
|
||||
root.add(folderNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picking a snippet opens it where the first biome using it places it; its children open the other uses.
|
||||
private DefaultMutableTreeNode snippetNode(PaletteCatalog.SnippetEntry snippet) {
|
||||
if (snippet.uses().isEmpty()) {
|
||||
return new DefaultMutableTreeNode(new OverviewNode(snippet.path(), "not used by any biome", null, null));
|
||||
}
|
||||
BiomePalette firstUse = snippet.uses().get(0);
|
||||
String usedBy = snippet.uses().size() == 1 ? "used once" : "used " + snippet.uses().size() + " times";
|
||||
DefaultMutableTreeNode node = new DefaultMutableTreeNode(new OverviewNode(snippet.path() + " · " + snippet.blocks(),
|
||||
usedBy, firstUse.biomeKey(), firstUse));
|
||||
for (BiomePalette use : snippet.uses()) {
|
||||
BiomeEntry biome = catalog.biome(use.biomeKey());
|
||||
String biomeName = biome == null ? use.biomeKey() : biome.name();
|
||||
node.add(new DefaultMutableTreeNode(new OverviewNode(biomeName + " · " + use.lowercaseLabel(), use.biomeKey(),
|
||||
use.biomeKey(), use)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
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() {
|
||||
if (selectedNodeShowsSelectedPalette()) {
|
||||
return;
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// The same palette can be listed under its biome and under its snippet; the row the user picked stays picked.
|
||||
private boolean selectedNodeShowsSelectedPalette() {
|
||||
return nodeAt(overviewTree.getSelectionPath()) instanceof OverviewNode node
|
||||
&& node.palette() != null
|
||||
&& node.palette().equals(selectedPalette);
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode findLayerNode(DefaultMutableTreeNode root) {
|
||||
Enumeration<?> nodes = root.depthFirstEnumeration();
|
||||
while (nodes.hasMoreElements()) {
|
||||
DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
|
||||
if (node.getUserObject() instanceof OverviewNode overview
|
||||
&& overview.palette() != null
|
||||
&& overview.palette().equals(selectedPalette)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void selectInHereList() {
|
||||
for (int index = 0; index < hereRows.size(); index++) {
|
||||
HereRow row = hereRows.get(index);
|
||||
if (row.palette() != null && row.palette().equals(selectedPalette)) {
|
||||
hereList.setSelectedIndex(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
hereList.clearSelection();
|
||||
}
|
||||
|
||||
private static Object nodeAt(TreePath path) {
|
||||
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
|
||||
private static String biomeText(BiomeEntry biome, String note) {
|
||||
String noteText = note == null ? "" : " · " + escape(note);
|
||||
return "<html>" + escape(biome.name()) + noteText + " <font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + escape(biome.key())
|
||||
+ "</font></html>";
|
||||
}
|
||||
|
||||
private static String labeled(String title, String name, String key) {
|
||||
return "<html><font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + title + "</font> " + escape(name)
|
||||
+ " <font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + escape(key) + "</font></html>";
|
||||
}
|
||||
|
||||
private static String escape(String text) {
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onPaletteChosen(BiomePalette palette);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nesting how many parent biomes deep the row's biome sits
|
||||
*/
|
||||
private record HereRow(String text, String biomeKey, BiomePalette palette, int nesting, boolean header) {
|
||||
private static HereRow header(String text) {
|
||||
return new HereRow(text, null, null, 0, true);
|
||||
}
|
||||
|
||||
private static HereRow biome(String text, String biomeKey, int nesting) {
|
||||
return new HereRow(text, biomeKey, null, nesting, false);
|
||||
}
|
||||
|
||||
private static HereRow palette(String text, BiomePalette palette, int nesting) {
|
||||
return new HereRow(text, palette.biomeKey(), palette, nesting, false);
|
||||
}
|
||||
}
|
||||
|
||||
private record OverviewNode(String label, String detail, String biomeKey, BiomePalette palette) {
|
||||
private String html() {
|
||||
if (detail == null) {
|
||||
return escape(label);
|
||||
}
|
||||
return "<html>" + escape(label) + " <font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + escape(detail) + "</font></html>";
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HereRowRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 2785341117408251320L;
|
||||
private static final int HEADER_INDENT = 11;
|
||||
private static final int BIOME_INDENT = 20;
|
||||
private static final int PALETTE_INDENT = 34;
|
||||
private static final int CHILD_INDENT = 16;
|
||||
|
||||
@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.header() ? row.text().toUpperCase(Locale.ROOT) : row.text(), index,
|
||||
selected && selectable, false);
|
||||
setFont(row.header() ? StudioTheme.sectionFont() : StudioTheme.font());
|
||||
if (row.header()) {
|
||||
setForeground(StudioTheme.MUTED_TEXT);
|
||||
}
|
||||
int indent = row.header() ? HEADER_INDENT
|
||||
: (row.palette() != null ? PALETTE_INDENT : BIOME_INDENT) + row.nesting() * CHILD_INDENT;
|
||||
setBorder(StudioTheme.emptyBorder(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);
|
||||
}
|
||||
|
||||
@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);
|
||||
boolean groupsOthers = userObject instanceof OverviewNode node && node.biomeKey() == null && !leaf;
|
||||
setFont(groupsOthers ? StudioTheme.boldFont() : StudioTheme.font());
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
* 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.components.PickDisplay;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.Scrollable;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.text.DefaultCaret;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The inspector of the region and biome views: how noise picks the regions or biomes in view, measured against what
|
||||
* is in view and put in words, with a switch for how the map draws the picks. What the user opens or closes stays so
|
||||
* while the view is measured again.
|
||||
*/
|
||||
final class PickInspectorPanel extends JPanel {
|
||||
private static final long serialVersionUID = -2874105263188240317L;
|
||||
private static final String MESSAGE_CARD = "message";
|
||||
private static final String DETAILS_CARD = "details";
|
||||
private static final int PANEL_WIDTH = 330;
|
||||
private static final String PARAGRAPH_BREAK = "\n\n";
|
||||
|
||||
private final JLabel title = new JLabel();
|
||||
private final JLabel detail = new JLabel();
|
||||
private final Map<PickDisplay, JToggleButton> displayButtons = new EnumMap<>(PickDisplay.class);
|
||||
private final CardLayout cards = new CardLayout();
|
||||
private final JPanel cardPanel = new JPanel(cards);
|
||||
private final JTextArea message = wrappingText();
|
||||
private final JPanel details = new ViewportWidthPanel();
|
||||
private final JScrollPane detailsScroll = StudioToolbar.scroll(details);
|
||||
private final Set<String> expandedSectionKeys = new HashSet<>();
|
||||
private final Set<String> fullTableKeys = new HashSet<>();
|
||||
private final String picksName;
|
||||
private boolean sectionsChosenByUser;
|
||||
private String shownDetailsTitle;
|
||||
|
||||
/**
|
||||
* @param picksName what the map shows when it draws the picks: "Regions" or "Biomes"
|
||||
* @param onDisplayChosen receives how the map should draw the picks
|
||||
*/
|
||||
PickInspectorPanel(String picksName, Consumer<PickDisplay> onDisplayChosen) {
|
||||
super(new BorderLayout());
|
||||
this.picksName = picksName;
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
add(buildHeader(onDisplayChosen), BorderLayout.NORTH);
|
||||
cardPanel.setOpaque(false);
|
||||
cardPanel.add(buildMessageCard(), MESSAGE_CARD);
|
||||
details.setLayout(new BoxLayout(details, BoxLayout.Y_AXIS));
|
||||
details.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
cardPanel.add(detailsScroll, DETAILS_CARD);
|
||||
add(cardPanel, BorderLayout.CENTER);
|
||||
showMeasuring(picksName);
|
||||
}
|
||||
|
||||
void selectDisplay(PickDisplay display) {
|
||||
displayButtons.get(display).setSelected(true);
|
||||
}
|
||||
|
||||
void showMeasuring(String name) {
|
||||
showHeader(name, "Measuring what is in view…");
|
||||
showMessage("");
|
||||
}
|
||||
|
||||
void showRegionPicks(NoisePickSpread spread, List<String> notes) {
|
||||
showHeader("Regions", "Region noise, split by rarity");
|
||||
nameFirstDisplay("Regions");
|
||||
showDetails(List.of(noiseChart(spread, "Regions", "regions")), notes);
|
||||
}
|
||||
|
||||
void showBiomePicks(String regionName, List<NoisePickSpread> spreads, List<String> notes) {
|
||||
showHeader(regionName, "Biome terrain · biome noise, split by rarity");
|
||||
nameFirstDisplay("Biomes");
|
||||
showDetails(spreads.stream().map(spread -> (JComponent) noiseChart(spread, "Biomes", spread.title())).toList(), notes);
|
||||
}
|
||||
|
||||
void showCoverage(String regionName, RegionHeightCoverage coverage, List<String> notes) {
|
||||
showHeader(regionName, "Hybrid terrain · biomes by height");
|
||||
nameFirstDisplay("Biomes");
|
||||
showDetails(List.of(heightChart(coverage)), notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible section per region in view, each with how it picks its biomes, then notes about all of them.
|
||||
* Until the user opens or closes one, only the first is open.
|
||||
*/
|
||||
void showRegionSections(String detailText, List<Section> sections, List<String> notes) {
|
||||
showHeader(picksName, detailText);
|
||||
nameFirstDisplay(picksName);
|
||||
if (!sectionsChosenByUser) {
|
||||
expandedSectionKeys.clear();
|
||||
sections.stream().findFirst().ifPresent(first -> expandedSectionKeys.add(first.key()));
|
||||
}
|
||||
List<JComponent> components = new ArrayList<>();
|
||||
for (Section section : sections) {
|
||||
components.add(new SectionPanel(section, expandedSectionKeys.contains(section.key())));
|
||||
}
|
||||
showDetails(components, notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* A chart whose long table remembers, by this key, whether the user asked for all its rows.
|
||||
*/
|
||||
NoisePickChart noiseChart(NoisePickSpread spread, String optionNoun, String key) {
|
||||
return new NoisePickChart(spread, optionNoun, fullTableKeys.contains(key), showsAll -> {
|
||||
if (showsAll) {
|
||||
fullTableKeys.add(key);
|
||||
} else {
|
||||
fullTableKeys.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static RegionHeightChart heightChart(RegionHeightCoverage coverage) {
|
||||
RegionHeightChart chart = new RegionHeightChart();
|
||||
chart.setAlignmentX(LEFT_ALIGNMENT);
|
||||
chart.show(coverage);
|
||||
return chart;
|
||||
}
|
||||
|
||||
private JPanel buildHeader(Consumer<PickDisplay> onDisplayChosen) {
|
||||
JPanel header = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(6)));
|
||||
header.setOpaque(false);
|
||||
header.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(10, 16, 12, 16)));
|
||||
title.setFont(StudioTheme.titleFont());
|
||||
detail.setFont(StudioTheme.smallFont());
|
||||
detail.setForeground(StudioTheme.MUTED_TEXT);
|
||||
header.add(title);
|
||||
header.add(detail);
|
||||
header.add(sectionLabel("Show"));
|
||||
header.add(buildDisplayButtons(onDisplayChosen));
|
||||
return header;
|
||||
}
|
||||
|
||||
private JPanel buildDisplayButtons(Consumer<PickDisplay> onDisplayChosen) {
|
||||
JPanel row = new JPanel(new GridLayout(1, 0, StudioTheme.scale(4), 0));
|
||||
row.setOpaque(false);
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
for (PickDisplay display : PickDisplay.values()) {
|
||||
JToggleButton button = new JToggleButton(displayName(display));
|
||||
button.setFocusable(false);
|
||||
button.setSelected(display == PickDisplay.PICKS);
|
||||
button.addActionListener(event -> onDisplayChosen.accept(display));
|
||||
group.add(button);
|
||||
row.add(button);
|
||||
displayButtons.put(display, button);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private String displayName(PickDisplay display) {
|
||||
return switch (display) {
|
||||
case PICKS -> picksName;
|
||||
case NOISE -> "Noise";
|
||||
case BOTH -> "Both";
|
||||
};
|
||||
}
|
||||
|
||||
private void nameFirstDisplay(String name) {
|
||||
displayButtons.get(PickDisplay.PICKS).setText(name);
|
||||
}
|
||||
|
||||
private JPanel buildMessageCard() {
|
||||
JPanel card = new JPanel(new BorderLayout());
|
||||
card.setOpaque(false);
|
||||
message.setBorder(StudioTheme.emptyBorder(12, 16, 12, 16));
|
||||
card.add(message, BorderLayout.NORTH);
|
||||
return card;
|
||||
}
|
||||
|
||||
private void showHeader(String name, String detailText) {
|
||||
title.setText(name);
|
||||
detail.setText(detailText);
|
||||
}
|
||||
|
||||
private void rememberSectionExpanded(String key, boolean expanded) {
|
||||
sectionsChosenByUser = true;
|
||||
if (expanded) {
|
||||
expandedSectionKeys.add(key);
|
||||
} else {
|
||||
expandedSectionKeys.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void showMessage(String text) {
|
||||
message.setText(text);
|
||||
cards.show(cardPanel, MESSAGE_CARD);
|
||||
}
|
||||
|
||||
// Measuring the same subject again keeps the scroll position, so panning the map doesn't jump the list.
|
||||
private void showDetails(List<JComponent> charts, List<String> notes) {
|
||||
boolean sameSubject = Objects.equals(title.getText(), shownDetailsTitle);
|
||||
int scrollPosition = sameSubject ? detailsScroll.getVerticalScrollBar().getValue() : 0;
|
||||
shownDetailsTitle = title.getText();
|
||||
details.removeAll();
|
||||
charts.forEach(details::add);
|
||||
if (!notes.isEmpty()) {
|
||||
details.add(notesText(notes));
|
||||
}
|
||||
details.revalidate();
|
||||
details.repaint();
|
||||
cards.show(cardPanel, DETAILS_CARD);
|
||||
SwingUtilities.invokeLater(() -> detailsScroll.getVerticalScrollBar().setValue(scrollPosition));
|
||||
}
|
||||
|
||||
private static JTextArea notesText(List<String> notes) {
|
||||
JTextArea text = wrappingText();
|
||||
text.setAlignmentX(LEFT_ALIGNMENT);
|
||||
text.setBorder(StudioTheme.emptyBorder(4, 16, 12, 16));
|
||||
text.setText(String.join(PARAGRAPH_BREAK, notes));
|
||||
return text;
|
||||
}
|
||||
|
||||
private static JLabel sectionLabel(String text) {
|
||||
JLabel label = new JLabel(text.toUpperCase(Locale.ROOT));
|
||||
label.setForeground(StudioTheme.MUTED_TEXT);
|
||||
label.setFont(StudioTheme.sectionFont());
|
||||
return label;
|
||||
}
|
||||
|
||||
private static JTextArea wrappingText() {
|
||||
JTextArea text = new JTextArea();
|
||||
text.setEditable(false);
|
||||
text.setFocusable(false);
|
||||
text.setOpaque(false);
|
||||
((DefaultCaret) text.getCaret()).setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
|
||||
text.setLineWrap(true);
|
||||
text.setWrapStyleWord(true);
|
||||
text.setFont(StudioTheme.font());
|
||||
text.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* One region of the biome view's inspector.
|
||||
*
|
||||
* @param share how much of the view the region covers
|
||||
* @param summary what the closed section says about its content, like "7 biomes"
|
||||
*/
|
||||
record Section(String key, String name, int color, double share, String summary, List<JComponent> charts,
|
||||
List<String> notes) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A region's header, which opens and closes the charts and notes below it.
|
||||
*/
|
||||
private final class SectionPanel extends JPanel {
|
||||
private static final long serialVersionUID = 2318546098861530717L;
|
||||
|
||||
private final JPanel body = new JPanel();
|
||||
|
||||
private SectionPanel(Section section, boolean expanded) {
|
||||
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
|
||||
setOpaque(false);
|
||||
setAlignmentX(LEFT_ALIGNMENT);
|
||||
body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS));
|
||||
body.setOpaque(false);
|
||||
body.setAlignmentX(LEFT_ALIGNMENT);
|
||||
section.charts().forEach(body::add);
|
||||
if (!section.notes().isEmpty()) {
|
||||
body.add(notesText(section.notes()));
|
||||
}
|
||||
body.setVisible(expanded);
|
||||
add(new SectionHeader(section, body::isVisible, () -> toggle(section)));
|
||||
add(body);
|
||||
}
|
||||
|
||||
private void toggle(Section section) {
|
||||
body.setVisible(!body.isVisible());
|
||||
rememberSectionExpanded(section.key(), body.isVisible());
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A region's name in its colour behind an open or closed chevron, with what it holds and how much of the view it
|
||||
* covers; clicking it opens or closes the region.
|
||||
*/
|
||||
private static final class SectionHeader extends JComponent {
|
||||
private static final long serialVersionUID = -4730226930154581123L;
|
||||
private static final int HEIGHT = 32;
|
||||
private static final int MARGIN = 16;
|
||||
private static final int CHEVRON_WIDTH = 16;
|
||||
private static final int SWATCH_SIZE = 12;
|
||||
private static final int SWATCH_GAP = 8;
|
||||
private static final int SUMMARY_GAP = 8;
|
||||
|
||||
private final Section section;
|
||||
private final BooleanSupplier expanded;
|
||||
|
||||
private SectionHeader(Section section, BooleanSupplier expanded, Runnable onToggle) {
|
||||
this.section = section;
|
||||
this.expanded = expanded;
|
||||
setAlignmentX(LEFT_ALIGNMENT);
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
onToggle.run();
|
||||
repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
return StudioTheme.scaledSize(PANEL_WIDTH, HEIGHT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMaximumSize() {
|
||||
return new Dimension(Integer.MAX_VALUE, getPreferredSize().height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setColor(StudioTheme.SEPARATOR);
|
||||
canvas.fillRect(0, 0, getWidth(), 1);
|
||||
int left = StudioTheme.scale(MARGIN) - StudioTheme.scale(4);
|
||||
paintChevron(canvas, left);
|
||||
left += StudioTheme.scale(CHEVRON_WIDTH);
|
||||
int swatch = StudioTheme.scale(SWATCH_SIZE);
|
||||
canvas.setColor(new Color(section.color()));
|
||||
int arc = StudioTheme.scale(4);
|
||||
canvas.fillRoundRect(left, (getHeight() - swatch) / 2, swatch, swatch, arc, arc);
|
||||
left += swatch + StudioTheme.scale(SWATCH_GAP);
|
||||
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
FontMetrics small = canvas.getFontMetrics();
|
||||
String share = NoisePickChart.percent(section.share());
|
||||
int shareLeft = getWidth() - StudioTheme.scale(MARGIN) - small.stringWidth(share);
|
||||
canvas.setFont(StudioTheme.boldFont());
|
||||
FontMetrics bold = canvas.getFontMetrics();
|
||||
int baseline = (getHeight() + bold.getAscent() - bold.getDescent()) / 2;
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
canvas.drawString(section.name(), left, baseline);
|
||||
left += bold.stringWidth(section.name()) + StudioTheme.scale(SUMMARY_GAP);
|
||||
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
if (section.summary() != null && left + small.stringWidth(section.summary()) < shareLeft - StudioTheme.scale(SUMMARY_GAP)) {
|
||||
canvas.drawString(section.summary(), left, baseline);
|
||||
}
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
canvas.drawString(share, shareLeft, baseline);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void paintChevron(Graphics2D canvas, int left) {
|
||||
Icon chevron = UIManager.getIcon(expanded.getAsBoolean() ? "Tree.expandedIcon" : "Tree.collapsedIcon");
|
||||
if (chevron == null) {
|
||||
return;
|
||||
}
|
||||
chevron.paintIcon(this, canvas, left + (StudioTheme.scale(CHEVRON_WIDTH) - chevron.getIconWidth()) / 2,
|
||||
(getHeight() - chevron.getIconHeight()) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the scroll pane's width, so the charts stretch to it and the notes wrap inside it.
|
||||
*/
|
||||
private static final class ViewportWidthPanel extends JPanel implements Scrollable {
|
||||
private static final long serialVersionUID = 6086542174870301352L;
|
||||
private static final int SCROLL_STEP = 18;
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
|
||||
return StudioTheme.scale(SCROLL_STEP);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) {
|
||||
return orientation == SwingConstants.VERTICAL ? visibleRect.height : visibleRect.width;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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.NoisePickSpread.Option;
|
||||
import com.volmit.iris.core.gui.RegionHeightCoverage.BiomeRange;
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Samples the map in view to measure how regions and biomes are picked, and puts what stands out in words; shared by
|
||||
* the region and biome views.
|
||||
*/
|
||||
final class PickMeasuring {
|
||||
static final List<InferredType> PLACED_TYPES = List.of(InferredType.LAND, InferredType.SHORE, InferredType.SEA);
|
||||
private static final int SAMPLES_PER_SIDE = 96;
|
||||
private static final int NAMED_IN_NOTES = 3;
|
||||
|
||||
private PickMeasuring() {
|
||||
}
|
||||
|
||||
static void forEachSample(MapCensus.ViewArea area, SampleVisitor visitor) {
|
||||
double stepX = (area.maximumX() - area.minimumX()) / SAMPLES_PER_SIDE;
|
||||
double stepZ = (area.maximumZ() - area.minimumZ()) / SAMPLES_PER_SIDE;
|
||||
for (int row = 0; row < SAMPLES_PER_SIDE; row++) {
|
||||
for (int column = 0; column < SAMPLES_PER_SIDE; column++) {
|
||||
visitor.visit(area.minimumX() + (column + 0.5D) * stepX, area.minimumZ() + (row + 0.5D) * stepZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Option regionOption(Engine engine, IrisRegion region) {
|
||||
return new Option(region.getLoadKey(), region.getName(), regionColor(engine, region), region.getRarity());
|
||||
}
|
||||
|
||||
static Option biomeOption(Engine engine, IrisBiome biome) {
|
||||
return new Option(biome.getLoadKey(), biome.getName(), biome.getColor(engine, RenderType.BIOME).getRGB() & 0xFFFFFF,
|
||||
biome.getRarity());
|
||||
}
|
||||
|
||||
static int regionColor(Engine engine, IrisRegion region) {
|
||||
return region.getColor(engine.getComplex(), RenderType.REGION).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* One spread per biome list of the region that has biomes, in land, shore, sea order.
|
||||
*/
|
||||
static List<TypedSpread> biomeSpreads(Engine engine, IrisComplex complex, IrisRegion region) {
|
||||
List<TypedSpread> spreads = new ArrayList<>();
|
||||
for (InferredType type : PLACED_TYPES) {
|
||||
List<Option> options = complex.noisePickedBiomes(region, type).stream()
|
||||
.map(biome -> biomeOption(engine, biome))
|
||||
.toList();
|
||||
if (!options.isEmpty()) {
|
||||
spreads.add(new TypedSpread(type,
|
||||
NoisePickSpread.of(typeName(type) + " biome noise in view", options, complex.isLegacyRarity())));
|
||||
}
|
||||
}
|
||||
return spreads;
|
||||
}
|
||||
|
||||
static List<BiomeRange> biomeRanges(Engine engine, IrisRegion region) {
|
||||
List<BiomeRange> ranges = new ArrayList<>();
|
||||
for (InferredType type : PLACED_TYPES) {
|
||||
for (IrisBiome biome : region.getBiomes(engine.getComplex(), type)) {
|
||||
if (biome == null) {
|
||||
continue;
|
||||
}
|
||||
ranges.add(new BiomeRange(biome.getLoadKey(), biome.getName(),
|
||||
biome.getColor(engine, RenderType.BIOME).getRGB() & 0xFFFFFF, type,
|
||||
biome.getPlacement().getMinHeight(), biome.getPlacement().getMaxHeight()));
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
static void addSpreadNotes(List<String> notes, NoisePickSpread spread, String noun) {
|
||||
List<NoisePickSpread.Row> rows = spread.rows();
|
||||
if (spread.sampleCount() == 0) {
|
||||
notes.add("No " + noun + " is picked in view.");
|
||||
return;
|
||||
}
|
||||
if (rows.size() == 1) {
|
||||
return;
|
||||
}
|
||||
if (spread.belowZeroShare() > 0D) {
|
||||
notes.add(NoisePickChart.percent(spread.belowZeroShare()) + " of the " + noun + " noise in view is below 0, so "
|
||||
+ rows.getFirst().option().name() + " gets it.");
|
||||
}
|
||||
if (spread.aboveOneShare() > 0D) {
|
||||
notes.add(NoisePickChart.percent(spread.aboveOneShare()) + " of the " + noun + " noise in view is above 1, so "
|
||||
+ rows.getLast().option().name() + " gets it.");
|
||||
}
|
||||
List<String> starved = rows.stream().filter(NoisePickSpread.Row::isFarBelowWeight).map(row -> row.option().name()).toList();
|
||||
if (!starved.isEmpty()) {
|
||||
boolean single = starved.size() == 1;
|
||||
notes.add(namedFew(starved) + (single ? " gets" : " get") + " under half " + (single ? "its" : "their")
|
||||
+ " weight in view (▼): the " + noun + " noise rarely reaches " + (single ? "its band." : "their bands."));
|
||||
}
|
||||
}
|
||||
|
||||
static void addBiomeSpreadNotes(List<String> notes, List<TypedSpread> spreads) {
|
||||
for (TypedSpread typed : spreads) {
|
||||
addSpreadNotes(notes, typed.spread(), typeName(typed.type()).toLowerCase(Locale.ROOT) + " biome");
|
||||
}
|
||||
}
|
||||
|
||||
static void addChildBiomeNote(List<String> notes, IrisComplex complex, IrisRegion region) {
|
||||
List<String> parents = PLACED_TYPES.stream()
|
||||
.flatMap(type -> complex.noisePickedBiomes(region, type).stream())
|
||||
.filter(biome -> !biome.getChildren().isEmpty())
|
||||
.map(IrisBiome::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!parents.isEmpty()) {
|
||||
notes.add(namedFew(parents) + " then hand some spots to child biomes by a noise of their own.");
|
||||
}
|
||||
}
|
||||
|
||||
static void addRarityNotes(List<String> notes, IrisComplex complex, Collection<NoisePickSpread> spreads) {
|
||||
if (spreads.stream().allMatch(spread -> spread.bands().size() <= 1)) {
|
||||
return;
|
||||
}
|
||||
notes.add(complex.isLegacyRarity()
|
||||
? "Legacy rarity: each weight is the total rarity minus the option's own."
|
||||
: "Each weight is one over the rarity, so rarity 2 gets half the noise of rarity 1.");
|
||||
}
|
||||
|
||||
// A long list of names buries the point of a note, so only the first few are named.
|
||||
private static String namedFew(List<String> names) {
|
||||
if (names.size() <= NAMED_IN_NOTES) {
|
||||
return String.join(", ", names);
|
||||
}
|
||||
return String.join(", ", names.subList(0, NAMED_IN_NOTES)) + " and " + (names.size() - NAMED_IN_NOTES) + " more";
|
||||
}
|
||||
|
||||
static String measuredNote() {
|
||||
return "Measured on a " + SAMPLES_PER_SIDE + " × " + SAMPLES_PER_SIDE + " grid over the map in view.";
|
||||
}
|
||||
|
||||
static String hybridNoiseNote() {
|
||||
return "Noise shows the height biomes are picked at: the overlay noise plus the dimension's biome placement "
|
||||
+ "jitter. Where ranges share a height, each biome's competition noise, divided by its rarity, decides.";
|
||||
}
|
||||
|
||||
static String typeName(InferredType type) {
|
||||
String name = type.name().toLowerCase(Locale.ROOT);
|
||||
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface SampleVisitor {
|
||||
void visit(double x, double z);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spread of the noise that picks from one of a region's biome lists.
|
||||
*/
|
||||
record TypedSpread(InferredType type, NoisePickSpread spread) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 UUID worldId;
|
||||
private final Listener listener;
|
||||
private BukkitTask task;
|
||||
private PlayerLocation lastLocation;
|
||||
|
||||
private PlayerLocationTracker(UUID playerId, UUID worldId, Listener listener) {
|
||||
this.playerId = playerId;
|
||||
this.worldId = worldId;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
static PlayerLocationTracker follow(UUID playerId, UUID worldId, Listener listener) {
|
||||
PlayerLocationTracker tracker = new PlayerLocationTracker(playerId, worldId, 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;
|
||||
}
|
||||
if (player.getWorld() == null || !player.getWorld().getUID().equals(worldId)) {
|
||||
return PlayerLocation.IN_OTHER_WORLD;
|
||||
}
|
||||
Engine engine = engineAt(player.getLocation());
|
||||
if (engine == null) {
|
||||
return PlayerLocation.IN_OTHER_WORLD;
|
||||
}
|
||||
Location position = player.getLocation();
|
||||
IrisRegion region = engine.getRegion(position);
|
||||
String surfaceBiome = keyOf(engine.getSurfaceBiome(position.getBlockX(), position.getBlockZ()));
|
||||
String biomeAtPosition = keyOf(engine.getBiomeOrMantle(position));
|
||||
if (region == null || region.getLoadKey() == null || surfaceBiome == null) {
|
||||
return PlayerLocation.IN_OTHER_WORLD;
|
||||
}
|
||||
return new PlayerLocation(Status.IN_REGION, region.getLoadKey(), surfaceBiome, biomeAtPosition);
|
||||
}
|
||||
|
||||
private static String keyOf(IrisBiome biome) {
|
||||
return biome == null ? null : 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,
|
||||
IN_OTHER_WORLD,
|
||||
IN_REGION
|
||||
}
|
||||
|
||||
// Biomes are 3D: underground, the biome at the player's position can differ from the surface biome above it.
|
||||
record PlayerLocation(Status status, String regionKey, String surfaceBiomeKey, String biomeAtPositionKey) {
|
||||
static final PlayerLocation OFFLINE = new PlayerLocation(Status.OFFLINE, null, null, null);
|
||||
static final PlayerLocation IN_OTHER_WORLD = new PlayerLocation(Status.IN_OTHER_WORLD, null, null, null);
|
||||
|
||||
boolean isBelowSurfaceBiome() {
|
||||
return biomeAtPositionKey != null && !biomeAtPositionKey.equals(surfaceBiomeKey);
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onLocationChanged(PlayerLocation location);
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ public class PregeneratorJob implements PregenListener {
|
||||
public void open() {
|
||||
J.a(() -> {
|
||||
try {
|
||||
frame = new JFrame("Pregen View");
|
||||
frame = GuiHost.createFrame("Pregen View");
|
||||
renderer = new PregenRenderer();
|
||||
frame.addKeyListener(renderer);
|
||||
renderer.l = new ReentrantLock();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.object.IrisObject;
|
||||
import com.volmit.iris.engine.object.procedural.ProceduralObjectCatalog;
|
||||
import com.volmit.iris.engine.object.procedural.ResolvedProceduralPlacement;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* The objects the pack's procedural settings generate, one entry per definition, in the folders the pack keeps them in.
|
||||
*/
|
||||
final class ProceduralObjectSource implements ObjectSource {
|
||||
private static final Map<String, String> FOLDER_LABEL_BY_KIND = Map.of(
|
||||
"trees", "Trees",
|
||||
"formations", "Formations",
|
||||
"crystals", "Crystals",
|
||||
"coral", "Coral",
|
||||
"fungi", "Fungi",
|
||||
"ruins", "Ruins");
|
||||
|
||||
private static final int EMPTY_ICON_SIZE = 40;
|
||||
|
||||
private Map<String, ResolvedProceduralPlacement> definitionsByKey = Map.of();
|
||||
|
||||
@Override
|
||||
public String noun() {
|
||||
return "procedural object";
|
||||
}
|
||||
|
||||
@Override
|
||||
public EmptyState emptyState() {
|
||||
return new EmptyState(MapViewIcon.large(MapView.PROCEDURAL, EMPTY_ICON_SIZE), "No procedural objects yet",
|
||||
"Declare them in a biome or region, or save a settings file per object, like",
|
||||
"snippet/" + ProceduralObjectCatalog.TREE_FOLDER + "/oak.json", "They appear here once the pack reloads.");
|
||||
}
|
||||
|
||||
// The type folder gets its label; the rest of the path stays as the pack has it.
|
||||
@Override
|
||||
public List<String> keys(IrisData data) {
|
||||
Map<String, ResolvedProceduralPlacement> byKey = new LinkedHashMap<>();
|
||||
for (ProceduralObjectCatalog.Definition definition : data.getProceduralObjects().definitions()) {
|
||||
String kind = definition.placement().kind();
|
||||
String key = FOLDER_LABEL_BY_KIND.getOrDefault(kind, kind) + definition.path().substring(kind.length());
|
||||
byKey.put(key, definition.placement());
|
||||
}
|
||||
definitionsByKey = byKey;
|
||||
return List.copyOf(byKey.keySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IrisObject> variants(IrisData data, String key) {
|
||||
ResolvedProceduralPlacement definition = definitionsByKey.get(key);
|
||||
return definition == null ? List.of() : definition.variants();
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong seed(String key) {
|
||||
ResolvedProceduralPlacement definition = definitionsByKey.get(key);
|
||||
return definition == null ? OptionalLong.empty() : OptionalLong.of(definition.seed());
|
||||
}
|
||||
|
||||
// The pack's own seed reuses the variants world generation bakes.
|
||||
@Override
|
||||
public List<IrisObject> variants(IrisData data, String key, long seed) {
|
||||
ResolvedProceduralPlacement definition = definitionsByKey.get(key);
|
||||
if (definition == null) {
|
||||
return List.of();
|
||||
}
|
||||
return definition.seed() == seed ? definition.variants() : data.getProceduralObjects().reseeded(definition, seed).variants();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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.PickMeasuring.TypedSpread;
|
||||
import com.volmit.iris.core.gui.RegionNavigator.RegionRow;
|
||||
import com.volmit.iris.core.gui.components.PickDisplay;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The region view's explorer: shows how the region noise picks the regions, and for a chosen region fills the map with
|
||||
* its biomes and shows how they are picked, measured against what is in view.
|
||||
*/
|
||||
final class RegionExplorer {
|
||||
private final Supplier<Engine> engineSource;
|
||||
private final Consumer<String> onPreviewChanged;
|
||||
private final RegionNavigator navigator = new RegionNavigator(this::choose);
|
||||
private final PickInspectorPanel inspector;
|
||||
private final LatestMeasurement<Measurement> measurer = new LatestMeasurement<>("Iris Region Explorer");
|
||||
private String chosenRegionKey;
|
||||
private MapCensus.ViewArea latestArea;
|
||||
private MapCensus.ViewArea measuredArea;
|
||||
|
||||
/**
|
||||
* @param onPreviewChanged receives the region to fill the map with, or null to show every region again
|
||||
* @param onDisplayChosen receives how the map should draw the picks
|
||||
*/
|
||||
RegionExplorer(Supplier<Engine> engineSource, Consumer<String> onPreviewChanged, Consumer<PickDisplay> onDisplayChosen) {
|
||||
this.engineSource = engineSource;
|
||||
this.onPreviewChanged = onPreviewChanged;
|
||||
this.inspector = new PickInspectorPanel("Regions", onDisplayChosen);
|
||||
}
|
||||
|
||||
RegionNavigator navigator() {
|
||||
return navigator;
|
||||
}
|
||||
|
||||
PickInspectorPanel inspector() {
|
||||
return inspector;
|
||||
}
|
||||
|
||||
void loadRegions() {
|
||||
Engine engine = engineSource.get();
|
||||
if (engine == null || engine.getComplex() == null) {
|
||||
return;
|
||||
}
|
||||
List<RegionRow> rows = new ArrayList<>();
|
||||
for (IrisRegion region : engine.getComplex().noisePickedRegions()) {
|
||||
rows.add(new RegionRow(region.getLoadKey(), region.getName(), PickMeasuring.regionColor(engine, region)));
|
||||
}
|
||||
navigator.showRegions(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the regions, or the chosen region's biomes, against this area, unless that was already done.
|
||||
*/
|
||||
void measure(MapCensus.ViewArea area) {
|
||||
latestArea = area;
|
||||
if (area == null || area.equals(measuredArea)) {
|
||||
return;
|
||||
}
|
||||
measuredArea = area;
|
||||
String regionKey = chosenRegionKey;
|
||||
measurer.measure(() -> measurePicks(regionKey, area), this::show);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pack changed, so the regions and their biomes are read again.
|
||||
*/
|
||||
void refresh() {
|
||||
loadRegions();
|
||||
measuredArea = null;
|
||||
measure(latestArea);
|
||||
}
|
||||
|
||||
void close() {
|
||||
measurer.close();
|
||||
}
|
||||
|
||||
private void choose(String regionKey) {
|
||||
chosenRegionKey = regionKey;
|
||||
measuredArea = null;
|
||||
measurer.discardPending();
|
||||
onPreviewChanged.accept(regionKey);
|
||||
inspector.showMeasuring(regionKey == null ? "Regions" : regionName(regionKey));
|
||||
measure(latestArea);
|
||||
}
|
||||
|
||||
private String regionName(String regionKey) {
|
||||
Engine engine = engineSource.get();
|
||||
IrisRegion region = engine == null ? null : engine.getData().getRegionLoader().load(regionKey);
|
||||
return region == null ? regionKey : region.getName();
|
||||
}
|
||||
|
||||
private void show(Measurement measurement) {
|
||||
if (!Objects.equals(measurement.regionKey(), chosenRegionKey)) {
|
||||
return;
|
||||
}
|
||||
switch (measurement) {
|
||||
case RegionPicks picks -> inspector.showRegionPicks(picks.spread(), picks.notes());
|
||||
case BiomePicks picks -> inspector.showBiomePicks(picks.regionName(), picks.spreads(), picks.notes());
|
||||
case HeightPicks picks -> inspector.showCoverage(picks.regionName(), picks.coverage(), picks.notes());
|
||||
}
|
||||
}
|
||||
|
||||
private Measurement measurePicks(String regionKey, MapCensus.ViewArea area) {
|
||||
Engine engine = engineSource.get();
|
||||
IrisComplex complex = engine == null ? null : engine.getComplex();
|
||||
if (complex == null) {
|
||||
return null;
|
||||
}
|
||||
if (regionKey == null) {
|
||||
return measureRegionPicks(engine, complex, area);
|
||||
}
|
||||
IrisRegion region = engine.getData().getRegionLoader().load(regionKey);
|
||||
if (region == null) {
|
||||
return null;
|
||||
}
|
||||
return complex.isHybridTerrain()
|
||||
? measureHeightPicks(engine, complex, region, area)
|
||||
: measureBiomePicks(engine, complex, region, area);
|
||||
}
|
||||
|
||||
private static RegionPicks measureRegionPicks(Engine engine, IrisComplex complex, MapCensus.ViewArea area) {
|
||||
List<NoisePickSpread.Option> options = complex.noisePickedRegions().stream()
|
||||
.map(region -> PickMeasuring.regionOption(engine, region))
|
||||
.toList();
|
||||
NoisePickSpread spread = NoisePickSpread.of("Region noise in view", options, complex.isLegacyRarity());
|
||||
PickMeasuring.forEachSample(area, (x, z) -> spread.add(complex.getRegionStyleStream().get(x, z)));
|
||||
List<String> notes = new ArrayList<>();
|
||||
PickMeasuring.addSpreadNotes(notes, spread, "region");
|
||||
PickMeasuring.addRarityNotes(notes, complex, List.of(spread));
|
||||
notes.add(PickMeasuring.measuredNote());
|
||||
return new RegionPicks(spread, notes);
|
||||
}
|
||||
|
||||
// The preview treats every spot as part of the region, so each spot's biome list is the one its surface needs.
|
||||
private static BiomePicks measureBiomePicks(Engine engine, IrisComplex complex, IrisRegion region, MapCensus.ViewArea area) {
|
||||
List<TypedSpread> spreads = PickMeasuring.biomeSpreads(engine, complex, region);
|
||||
PickMeasuring.forEachSample(area, (x, z) -> {
|
||||
InferredType type = complex.surfacePickAsIfIn(region, x, z).type();
|
||||
spreads.stream()
|
||||
.filter(typed -> typed.type() == type)
|
||||
.findFirst()
|
||||
.ifPresent(typed -> typed.spread().add(complex.biomePickNoise(region, type, x, z)));
|
||||
});
|
||||
List<String> notes = new ArrayList<>();
|
||||
PickMeasuring.addBiomeSpreadNotes(notes, spreads);
|
||||
PickMeasuring.addChildBiomeNote(notes, complex, region);
|
||||
List<NoisePickSpread> plainSpreads = spreads.stream().map(TypedSpread::spread).toList();
|
||||
PickMeasuring.addRarityNotes(notes, complex, plainSpreads);
|
||||
notes.add(PickMeasuring.measuredNote());
|
||||
return new BiomePicks(region.getLoadKey(), region.getName(), plainSpreads, notes);
|
||||
}
|
||||
|
||||
private static HeightPicks measureHeightPicks(Engine engine, IrisComplex complex, IrisRegion region, MapCensus.ViewArea area) {
|
||||
List<Integer> heights = new ArrayList<>();
|
||||
PickMeasuring.forEachSample(area, (x, z) -> heights.add((int) Math.round(complex.getPlacementHeightStream().get(x, z))));
|
||||
RegionHeightCoverage coverage = RegionHeightCoverage.measure(PickMeasuring.biomeRanges(engine, region),
|
||||
heights.stream().mapToInt(Integer::intValue).toArray());
|
||||
List<String> notes = new ArrayList<>(coverage.notes());
|
||||
notes.add(PickMeasuring.hybridNoiseNote());
|
||||
return new HeightPicks(region.getLoadKey(), region.getName(), coverage, notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* What was measured for the region view; the region key is null when every region was measured.
|
||||
*/
|
||||
private sealed interface Measurement permits RegionPicks, BiomePicks, HeightPicks {
|
||||
String regionKey();
|
||||
}
|
||||
|
||||
private record RegionPicks(NoisePickSpread spread, List<String> notes) implements Measurement {
|
||||
@Override
|
||||
public String regionKey() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record BiomePicks(String regionKey, String regionName, List<NoisePickSpread> spreads, List<String> notes)
|
||||
implements Measurement {
|
||||
}
|
||||
|
||||
private record HeightPicks(String regionKey, String regionName, RegionHeightCoverage coverage, List<String> notes)
|
||||
implements Measurement {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* 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.RegionHeightCoverage.BiomeRange;
|
||||
import com.volmit.iris.core.gui.RegionHeightCoverage.HeightBand;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.Shape;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ground height runs left to right. On top, how much of the ground in view sits at each height; below, one bar per
|
||||
* biome over the heights it allows. Heights no biome covers are red, heights several ranged biomes share are hatched.
|
||||
*/
|
||||
final class RegionHeightChart extends JComponent {
|
||||
private static final long serialVersionUID = 6218894112903760545L;
|
||||
private static final List<InferredType> ROW_GROUPS = List.of(InferredType.LAND, InferredType.SHORE, InferredType.SEA);
|
||||
private static final int CHART_WIDTH = 330;
|
||||
private static final int PADDING = 12;
|
||||
private static final int NAME_COLUMN = 104;
|
||||
private static final int NAME_GAP = 8;
|
||||
private static final int HISTOGRAM_HEIGHT = 54;
|
||||
private static final int AXIS_HEIGHT = 20;
|
||||
private static final int GROUP_HEADER_HEIGHT = 22;
|
||||
private static final int ROW_HEIGHT = 18;
|
||||
private static final int BAR_HEIGHT = 10;
|
||||
private static final int MINIMUM_TICK_SPACING = 38;
|
||||
private static final int[] TICK_STEPS = {5, 10, 20, 25, 50, 100, 200, 250, 500, 1000};
|
||||
private static final int HATCH_SPACING = 5;
|
||||
private static final double RANGE_PADDING_FRACTION = 0.04D;
|
||||
private static final Color LAND_GROUND = new ThemeColor(0x9DA0A8, 0x6F737A);
|
||||
private static final Color SEA_GROUND = new ThemeColor(0x7FA3D9, 0x4A6FA8);
|
||||
private static final Color GAP_FILL = new ThemeColor(new Color(0xDB, 0x3B, 0x4B, 46), new Color(0xF7, 0x54, 0x64, 52));
|
||||
private static final Color SHARED_HATCH = new ThemeColor(new Color(0, 0, 0, 70), new Color(255, 255, 255, 70));
|
||||
private static final Color SEA_LEVEL = new ThemeColor(new Color(0x31, 0x5F, 0xBD, 150), new Color(0x54, 0x8A, 0xF7, 150));
|
||||
|
||||
private RegionHeightCoverage coverage;
|
||||
private int lowestShown;
|
||||
private int highestShown;
|
||||
|
||||
RegionHeightChart() {
|
||||
setToolTipText("");
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
void show(RegionHeightCoverage nextCoverage) {
|
||||
coverage = nextCoverage;
|
||||
fitShownHeights();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
// The chart spans the ground in view and every range edge, so a biome placed above or below the view still shows.
|
||||
private void fitShownHeights() {
|
||||
if (coverage == null) {
|
||||
return;
|
||||
}
|
||||
int lowest = Math.min(0, coverage.lowestHeight());
|
||||
int highest = Math.max(0, coverage.highestHeight());
|
||||
for (InferredType type : ROW_GROUPS) {
|
||||
for (BiomeRange range : coverage.ranges(type)) {
|
||||
if (range.hasLowerBound()) {
|
||||
lowest = Math.min(lowest, range.minHeight());
|
||||
}
|
||||
if (range.hasUpperBound()) {
|
||||
highest = Math.max(highest, range.maxHeight());
|
||||
}
|
||||
}
|
||||
}
|
||||
int padding = Math.max(2, (int) Math.round((highest - lowest) * RANGE_PADDING_FRACTION));
|
||||
lowestShown = lowest - padding;
|
||||
highestShown = highest + padding;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
int rows = 0;
|
||||
int groups = 0;
|
||||
if (coverage != null) {
|
||||
for (InferredType type : ROW_GROUPS) {
|
||||
int count = coverage.ranges(type).size();
|
||||
rows += count;
|
||||
groups += count > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
int height = PADDING * 2 + HISTOGRAM_HEIGHT + AXIS_HEIGHT + groups * GROUP_HEADER_HEIGHT + rows * ROW_HEIGHT;
|
||||
return StudioTheme.scaledSize(CHART_WIDTH, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMaximumSize() {
|
||||
return new Dimension(Integer.MAX_VALUE, getPreferredSize().height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
if (coverage == null) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int top = StudioTheme.scale(PADDING);
|
||||
int rowsTop = top + StudioTheme.scale(HISTOGRAM_HEIGHT + AXIS_HEIGHT);
|
||||
int bottom = getHeight() - StudioTheme.scale(PADDING);
|
||||
drawBands(canvas, top, bottom);
|
||||
drawGround(canvas, top);
|
||||
drawAxis(canvas, top + StudioTheme.scale(HISTOGRAM_HEIGHT));
|
||||
drawRows(canvas, rowsTop);
|
||||
drawSeaLevel(canvas, top, bottom);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBands(Graphics2D canvas, int top, int bottom) {
|
||||
int histogramBottom = top + StudioTheme.scale(HISTOGRAM_HEIGHT);
|
||||
for (InferredType type : RegionHeightCoverage.MEASURED_TYPES) {
|
||||
for (HeightBand band : coverage.bands(type)) {
|
||||
int left = xOf(band.fromHeight());
|
||||
int width = Math.max(1, xOf(band.toHeight() + 1) - left);
|
||||
if (band.isGap()) {
|
||||
canvas.setColor(GAP_FILL);
|
||||
canvas.fillRect(left, top, width, bottom - top);
|
||||
} else if (band.isShared() && band.biomes().stream().allMatch(BiomeRange::isBounded)) {
|
||||
hatch(canvas, new Rectangle(left, top, width, histogramBottom - top));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void hatch(Graphics2D canvas, Rectangle area) {
|
||||
Shape previousClip = canvas.getClip();
|
||||
canvas.clip(area);
|
||||
canvas.setColor(SHARED_HATCH);
|
||||
int spacing = StudioTheme.scale(HATCH_SPACING);
|
||||
for (int offset = -area.height; offset < area.width; offset += spacing) {
|
||||
canvas.drawLine(area.x + offset, area.y + area.height, area.x + offset + area.height, area.y);
|
||||
}
|
||||
canvas.setClip(previousClip);
|
||||
}
|
||||
|
||||
private void drawGround(Graphics2D canvas, int top) {
|
||||
int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT);
|
||||
int plotLeft = plotLeft();
|
||||
int plotRight = plotRight();
|
||||
int[] columnCounts = new int[plotRight - plotLeft];
|
||||
int highestColumn = 1;
|
||||
for (int column = 0; column < columnCounts.length; column++) {
|
||||
int fromHeight = heightAt(plotLeft + column);
|
||||
int toHeight = Math.max(fromHeight, heightAt(plotLeft + column + 1) - 1);
|
||||
for (int height = fromHeight; height <= toHeight; height++) {
|
||||
columnCounts[column] += coverage.count(height);
|
||||
}
|
||||
highestColumn = Math.max(highestColumn, columnCounts[column]);
|
||||
}
|
||||
for (int column = 0; column < columnCounts.length; column++) {
|
||||
if (columnCounts[column] == 0) {
|
||||
continue;
|
||||
}
|
||||
int barHeight = Math.max(1, (int) Math.round(columnCounts[column] / (double) highestColumn * histogramHeight));
|
||||
canvas.setColor(heightAt(plotLeft + column) < 0 ? SEA_GROUND : LAND_GROUND);
|
||||
canvas.fillRect(plotLeft + column, top + histogramHeight - barHeight, 1, barHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawAxis(Graphics2D canvas, int axisTop) {
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawLine(plotLeft(), axisTop, plotRight(), axisTop);
|
||||
int step = tickStep();
|
||||
int firstTick = Math.floorDiv(lowestShown, step) * step + step;
|
||||
for (int height = firstTick; height < highestShown; height += step) {
|
||||
int x = xOf(height);
|
||||
String label = height > 0 ? "+" + height : String.valueOf(height);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawLine(x, axisTop, x, axisTop + StudioTheme.scale(3));
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(label, x - metrics.stringWidth(label) / 2, axisTop + StudioTheme.scale(4) + metrics.getAscent());
|
||||
}
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString("Heights in view", StudioTheme.scale(PADDING), axisTop - StudioTheme.scale(4));
|
||||
}
|
||||
|
||||
private int tickStep() {
|
||||
double pixelsPerBlock = (plotRight() - plotLeft()) / (double) Math.max(1, highestShown - lowestShown);
|
||||
for (int step : TICK_STEPS) {
|
||||
if (step * pixelsPerBlock >= StudioTheme.scale(MINIMUM_TICK_SPACING)) {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
return TICK_STEPS[TICK_STEPS.length - 1];
|
||||
}
|
||||
|
||||
private void drawRows(Graphics2D canvas, int rowsTop) {
|
||||
int y = rowsTop;
|
||||
for (InferredType type : ROW_GROUPS) {
|
||||
List<BiomeRange> ranges = coverage.ranges(type);
|
||||
if (ranges.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(groupTitle(type), StudioTheme.scale(PADDING), y + StudioTheme.scale(GROUP_HEADER_HEIGHT) - StudioTheme.scale(7));
|
||||
y += StudioTheme.scale(GROUP_HEADER_HEIGHT);
|
||||
for (BiomeRange range : ranges) {
|
||||
drawRow(canvas, range, y);
|
||||
y += StudioTheme.scale(ROW_HEIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawRow(Graphics2D canvas, BiomeRange range, int rowTop) {
|
||||
int rowHeight = StudioTheme.scale(ROW_HEIGHT);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
canvas.drawString(ellipsize(range.name(), metrics, StudioTheme.scale(NAME_COLUMN)), StudioTheme.scale(PADDING),
|
||||
rowTop + (rowHeight + metrics.getAscent() - metrics.getDescent()) / 2);
|
||||
int left = xOf(Math.max(range.minHeight(), lowestShown));
|
||||
int right = xOf(Math.min(range.maxHeight(), highestShown) + 1);
|
||||
int barHeight = StudioTheme.scale(BAR_HEIGHT);
|
||||
int barTop = rowTop + (rowHeight - barHeight) / 2;
|
||||
int arc = StudioTheme.scale(4);
|
||||
canvas.setColor(new Color(range.color()));
|
||||
canvas.fillRoundRect(left, barTop, Math.max(2, right - left), barHeight, arc, arc);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(left, barTop, Math.max(2, right - left) - 1, barHeight - 1, arc, arc);
|
||||
}
|
||||
|
||||
private void drawSeaLevel(Graphics2D canvas, int top, int bottom) {
|
||||
int x = xOf(0);
|
||||
canvas.setColor(SEA_LEVEL);
|
||||
float dash = StudioTheme.scale(3);
|
||||
canvas.setStroke(new BasicStroke(1F, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10F, new float[]{dash, dash}, 0F));
|
||||
canvas.drawLine(x, top, x, bottom);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolTipText(MouseEvent event) {
|
||||
if (coverage == null) {
|
||||
return null;
|
||||
}
|
||||
BiomeRange row = rowAt(event.getY());
|
||||
if (row != null) {
|
||||
return row.name() + ": " + (row.isBounded() ? RegionHeightCoverage.heightText(row.minHeight(), row.maxHeight()) : "any height");
|
||||
}
|
||||
int height = heightAt(event.getX());
|
||||
for (InferredType type : RegionHeightCoverage.MEASURED_TYPES) {
|
||||
for (HeightBand band : coverage.bands(type)) {
|
||||
if (band.isGap() && height >= band.fromHeight() && height <= band.toHeight()) {
|
||||
return "No biome covers " + RegionHeightCoverage.heightText(band.fromHeight(), band.toHeight());
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private BiomeRange rowAt(int y) {
|
||||
int rowTop = StudioTheme.scale(PADDING + HISTOGRAM_HEIGHT + AXIS_HEIGHT);
|
||||
for (InferredType type : ROW_GROUPS) {
|
||||
List<BiomeRange> ranges = coverage.ranges(type);
|
||||
if (ranges.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
rowTop += StudioTheme.scale(GROUP_HEADER_HEIGHT);
|
||||
for (BiomeRange range : ranges) {
|
||||
if (y >= rowTop && y < rowTop + StudioTheme.scale(ROW_HEIGHT)) {
|
||||
return range;
|
||||
}
|
||||
rowTop += StudioTheme.scale(ROW_HEIGHT);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int plotLeft() {
|
||||
return StudioTheme.scale(PADDING + NAME_COLUMN + NAME_GAP);
|
||||
}
|
||||
|
||||
private int plotRight() {
|
||||
return Math.max(plotLeft() + 1, getWidth() - StudioTheme.scale(PADDING));
|
||||
}
|
||||
|
||||
private int xOf(int height) {
|
||||
double fraction = (height - lowestShown) / (double) Math.max(1, highestShown - lowestShown);
|
||||
return plotLeft() + (int) Math.round(fraction * (plotRight() - plotLeft()));
|
||||
}
|
||||
|
||||
private int heightAt(int x) {
|
||||
double fraction = (x - plotLeft()) / (double) Math.max(1, plotRight() - plotLeft());
|
||||
return (int) Math.floor(lowestShown + fraction * (highestShown - lowestShown));
|
||||
}
|
||||
|
||||
private static String groupTitle(InferredType type) {
|
||||
return switch (type) {
|
||||
case LAND -> "LAND";
|
||||
case SHORE -> "SHORE";
|
||||
default -> "SEA";
|
||||
};
|
||||
}
|
||||
|
||||
private static String ellipsize(String text, FontMetrics metrics, int maximumWidth) {
|
||||
if (metrics.stringWidth(text) <= maximumWidth) {
|
||||
return text;
|
||||
}
|
||||
String ellipsis = "…";
|
||||
int end = text.length();
|
||||
while (end > 0 && metrics.stringWidth(text.substring(0, end) + ellipsis) > maximumWidth) {
|
||||
end--;
|
||||
}
|
||||
return text.substring(0, end) + ellipsis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.engine.object.InferredType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Which of a region's biomes allow which ground height in HYBRID terrain, measured against the ground heights the map
|
||||
* shows, so the region view can point out heights no biome covers and heights several biomes share.
|
||||
* Heights are in blocks above the fluid height, negative below it.
|
||||
*/
|
||||
final class RegionHeightCoverage {
|
||||
static final List<InferredType> MEASURED_TYPES = List.of(InferredType.SEA, InferredType.LAND);
|
||||
private static final int MAXIMUM_NOTES = 8;
|
||||
|
||||
private final List<BiomeRange> ranges;
|
||||
private final int lowestHeight;
|
||||
private final int highestHeight;
|
||||
private final int[] countsFromLowest;
|
||||
private final int highestCount;
|
||||
|
||||
private RegionHeightCoverage(List<BiomeRange> ranges, int lowestHeight, int[] countsFromLowest) {
|
||||
this.ranges = List.copyOf(ranges);
|
||||
this.lowestHeight = lowestHeight;
|
||||
this.highestHeight = lowestHeight + countsFromLowest.length - 1;
|
||||
this.countsFromLowest = countsFromLowest;
|
||||
int highest = 0;
|
||||
for (int count : countsFromLowest) {
|
||||
highest = Math.max(highest, count);
|
||||
}
|
||||
this.highestCount = highest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param groundHeights the ground height of every sampled spot, rounded to whole blocks
|
||||
*/
|
||||
static RegionHeightCoverage measure(List<BiomeRange> ranges, int[] groundHeights) {
|
||||
if (groundHeights.length == 0) {
|
||||
return new RegionHeightCoverage(ranges, 0, new int[1]);
|
||||
}
|
||||
int lowest = Integer.MAX_VALUE;
|
||||
int highest = Integer.MIN_VALUE;
|
||||
for (int height : groundHeights) {
|
||||
lowest = Math.min(lowest, height);
|
||||
highest = Math.max(highest, height);
|
||||
}
|
||||
int[] counts = new int[highest - lowest + 1];
|
||||
for (int height : groundHeights) {
|
||||
counts[height - lowest]++;
|
||||
}
|
||||
return new RegionHeightCoverage(ranges, lowest, counts);
|
||||
}
|
||||
|
||||
List<BiomeRange> ranges(InferredType type) {
|
||||
return ranges.stream().filter(range -> range.type() == type).toList();
|
||||
}
|
||||
|
||||
int lowestHeight() {
|
||||
return lowestHeight;
|
||||
}
|
||||
|
||||
int highestHeight() {
|
||||
return highestHeight;
|
||||
}
|
||||
|
||||
int count(int height) {
|
||||
return height < lowestHeight || height > highestHeight ? 0 : countsFromLowest[height - lowestHeight];
|
||||
}
|
||||
|
||||
int highestCount() {
|
||||
return highestCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground heights of one type split into runs that the same biomes allow, from low to high. Land is the ground
|
||||
* at or above the fluid height, sea the ground below it.
|
||||
*/
|
||||
List<HeightBand> bands(InferredType type) {
|
||||
int from = type == InferredType.SEA ? lowestHeight : Math.max(0, lowestHeight);
|
||||
int to = type == InferredType.SEA ? Math.min(-1, highestHeight) : highestHeight;
|
||||
int total = countBetween(from, to);
|
||||
List<HeightBand> bands = new ArrayList<>();
|
||||
if (total == 0) {
|
||||
return bands;
|
||||
}
|
||||
List<BiomeRange> typeRanges = ranges(type);
|
||||
int bandStart = from;
|
||||
List<BiomeRange> bandBiomes = coveringBiomes(typeRanges, from);
|
||||
for (int height = from + 1; height <= to + 1; height++) {
|
||||
List<BiomeRange> biomes = height <= to ? coveringBiomes(typeRanges, height) : null;
|
||||
if (bandBiomes.equals(biomes)) {
|
||||
continue;
|
||||
}
|
||||
bands.add(new HeightBand(type, bandStart, height - 1, bandBiomes, countBetween(bandStart, height - 1) / (double) total));
|
||||
bandStart = height;
|
||||
bandBiomes = biomes;
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
List<String> notes() {
|
||||
List<String> notes = new ArrayList<>();
|
||||
noteBiomesWithoutRange(notes);
|
||||
for (InferredType type : MEASURED_TYPES) {
|
||||
for (HeightBand band : bands(type)) {
|
||||
if (band.share() == 0D) {
|
||||
continue;
|
||||
}
|
||||
if (band.isGap()) {
|
||||
notes.add(gapNote(band));
|
||||
} else if (band.isShared() && band.biomes().stream().allMatch(BiomeRange::isBounded)) {
|
||||
notes.add(names(band.biomes()) + " share " + heightText(band.fromHeight(), band.toHeight())
|
||||
+ ": their noise decides.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return notes.size() > MAXIMUM_NOTES ? notes.subList(0, MAXIMUM_NOTES) : notes;
|
||||
}
|
||||
|
||||
private void noteBiomesWithoutRange(List<String> notes) {
|
||||
List<BiomeRange> unbounded = ranges.stream()
|
||||
.filter(range -> range.type() != InferredType.SHORE && !range.isBounded())
|
||||
.toList();
|
||||
if (unbounded.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean single = unbounded.size() == 1;
|
||||
notes.add(names(unbounded) + (single ? " has no height range, so it" : " have no height range, so they")
|
||||
+ " can show up at any height.");
|
||||
}
|
||||
|
||||
private String gapNote(HeightBand gap) {
|
||||
String ground = gap.type() == InferredType.SEA ? "sea floor" : "land";
|
||||
String note = "Nothing covers " + heightText(gap.fromHeight(), gap.toHeight()) + " (" + percent(gap.share())
|
||||
+ " of the " + ground + " in view)";
|
||||
BiomeRange below = closestBelow(gap);
|
||||
BiomeRange above = closestAbove(gap);
|
||||
if (below != null && above != null) {
|
||||
return note + ": " + below.name() + " and " + above.name() + ", the closest ranges, take it over.";
|
||||
}
|
||||
BiomeRange closest = below != null ? below : above;
|
||||
return closest == null ? note + "." : note + ": " + closest.name() + ", the closest range, takes it over.";
|
||||
}
|
||||
|
||||
private BiomeRange closestBelow(HeightBand gap) {
|
||||
return ranges(gap.type()).stream()
|
||||
.filter(range -> range.maxHeight() < gap.fromHeight())
|
||||
.max(Comparator.comparingInt(BiomeRange::maxHeight))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private BiomeRange closestAbove(HeightBand gap) {
|
||||
return ranges(gap.type()).stream()
|
||||
.filter(range -> range.minHeight() > gap.toHeight())
|
||||
.min(Comparator.comparingInt(BiomeRange::minHeight))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static List<BiomeRange> coveringBiomes(List<BiomeRange> typeRanges, int height) {
|
||||
return typeRanges.stream().filter(range -> range.covers(height)).toList();
|
||||
}
|
||||
|
||||
private int countBetween(int from, int to) {
|
||||
int total = 0;
|
||||
for (int height = from; height <= to; height++) {
|
||||
total += count(height);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
static String heightText(int from, int to) {
|
||||
if (from >= 0) {
|
||||
return from == to ? from + " above sea" : from + " to " + to + " above sea";
|
||||
}
|
||||
if (to < 0) {
|
||||
return from == to ? -from + " below sea" : -to + " to " + -from + " below sea";
|
||||
}
|
||||
return -from + " below to " + to + " above sea";
|
||||
}
|
||||
|
||||
private static String names(List<BiomeRange> biomes) {
|
||||
List<String> names = biomes.stream().map(BiomeRange::name).toList();
|
||||
if (names.size() == 1) {
|
||||
return names.get(0);
|
||||
}
|
||||
return names.subList(0, names.size() - 1).stream().collect(Collectors.joining(", ")) + " and " + names.get(names.size() - 1);
|
||||
}
|
||||
|
||||
private static String percent(double share) {
|
||||
double percent = share * 100D;
|
||||
return percent < 1D ? "<1%" : String.format(Locale.ROOT, "%.0f%%", percent);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heights one of the region's biomes allows, from its placement.
|
||||
*/
|
||||
record BiomeRange(String key, String name, int color, InferredType type, int minHeight, int maxHeight) {
|
||||
private static final int UNBOUNDED_LOW = -2032;
|
||||
private static final int UNBOUNDED_HIGH = 2032;
|
||||
|
||||
boolean covers(int height) {
|
||||
return height >= minHeight && height <= maxHeight;
|
||||
}
|
||||
|
||||
boolean isBounded() {
|
||||
return hasLowerBound() || hasUpperBound();
|
||||
}
|
||||
|
||||
boolean hasLowerBound() {
|
||||
return minHeight > UNBOUNDED_LOW;
|
||||
}
|
||||
|
||||
boolean hasUpperBound() {
|
||||
return maxHeight < UNBOUNDED_HIGH;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of ground heights that the same biomes allow; no biomes makes it a gap.
|
||||
*
|
||||
* @param share how much of this type's ground in view lies in the run
|
||||
*/
|
||||
record HeightBand(InferredType type, int fromHeight, int toHeight, List<BiomeRange> biomes, double share) {
|
||||
boolean isGap() {
|
||||
return biomes.isEmpty();
|
||||
}
|
||||
|
||||
boolean isShared() {
|
||||
return biomes.size() > 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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 javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The region view's sidebar: every region of the pack, or all of them at once, to fill the map with.
|
||||
*/
|
||||
final class RegionNavigator extends JPanel {
|
||||
private static final long serialVersionUID = 3150735486722101466L;
|
||||
private static final RegionRow ALL_REGIONS = new RegionRow(null, "All regions", -1);
|
||||
private static final int ROW_HEIGHT = 24;
|
||||
private static final int SWATCH_SIZE = 12;
|
||||
private static final int SWATCH_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
|
||||
private final Consumer<String> onRegionChosen;
|
||||
private final JTextField searchField = new JTextField();
|
||||
private final DefaultListModel<RegionRow> rows = new DefaultListModel<>();
|
||||
private final JList<RegionRow> list = new JList<>(rows);
|
||||
private List<RegionRow> regions = List.of();
|
||||
private String chosenKey;
|
||||
private boolean updatingRows;
|
||||
|
||||
/**
|
||||
* @param onRegionChosen receives the chosen region's key, or null for all regions
|
||||
*/
|
||||
RegionNavigator(Consumer<String> onRegionChosen) {
|
||||
super(new BorderLayout());
|
||||
this.onRegionChosen = onRegionChosen;
|
||||
StudioToolbar.makeSearchField(searchField, "Search regions...");
|
||||
searchField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
showMatchingRows();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
showMatchingRows();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
showMatchingRows();
|
||||
}
|
||||
});
|
||||
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new RegionRowRenderer());
|
||||
list.addListSelectionListener(event -> {
|
||||
RegionRow row = list.getSelectedValue();
|
||||
if (!event.getValueIsAdjusting() && !updatingRows && row != null && !Objects.equals(row.key(), chosenKey)) {
|
||||
chosenKey = row.key();
|
||||
onRegionChosen.accept(chosenKey);
|
||||
}
|
||||
});
|
||||
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||
add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
showMatchingRows();
|
||||
}
|
||||
|
||||
void showRegions(List<RegionRow> nextRegions) {
|
||||
regions = List.copyOf(nextRegions);
|
||||
showMatchingRows();
|
||||
}
|
||||
|
||||
private void showMatchingRows() {
|
||||
String filter = searchField.getText().trim().toLowerCase(Locale.ROOT);
|
||||
updatingRows = true;
|
||||
try {
|
||||
rows.clear();
|
||||
rows.addElement(ALL_REGIONS);
|
||||
for (RegionRow region : regions) {
|
||||
if (filter.isEmpty() || region.name().toLowerCase(Locale.ROOT).contains(filter)
|
||||
|| region.key().toLowerCase(Locale.ROOT).contains(filter)) {
|
||||
rows.addElement(region);
|
||||
}
|
||||
}
|
||||
selectChosenRow();
|
||||
} finally {
|
||||
updatingRows = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void selectChosenRow() {
|
||||
for (int index = 0; index < rows.size(); index++) {
|
||||
if (Objects.equals(rows.get(index).key(), chosenKey)) {
|
||||
list.setSelectedIndex(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
list.clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param color the region's map colour, or -1 for the all regions row
|
||||
*/
|
||||
record RegionRow(String key, String name, int color) {
|
||||
}
|
||||
|
||||
private static final class RegionRowRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = -6114522950207432516L;
|
||||
private Color swatch;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
RegionRow row = (RegionRow) value;
|
||||
super.getListCellRendererComponent(owner, row.name(), index, selected, false);
|
||||
setFont(row.key() == null ? StudioTheme.boldFont() : StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING));
|
||||
setToolTipText(row.key());
|
||||
swatch = row.color() < 0 ? null : new Color(row.color());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
if (swatch == null) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
int size = StudioTheme.scale(SWATCH_SIZE);
|
||||
int x = StudioTheme.scale(ROW_PADDING);
|
||||
int y = (getHeight() - size) / 2;
|
||||
int arc = StudioTheme.scale(4);
|
||||
canvas.setColor(swatch);
|
||||
canvas.fillRoundRect(x, y, size, size, arc, arc);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(x, y, size - 1, size - 1, arc, arc);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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 javax.swing.CellRendererPane;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLayer;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.JViewport;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.plaf.LayerUI;
|
||||
import javax.swing.plaf.basic.BasicTreeUI;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.AWTEvent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.GradientPaint;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Keeps the folders around the top row of a scrolled tree pinned at the top, so a long folder never hides where it
|
||||
* is. The next folder pushes the last header up as it arrives, and clicking a header jumps to its folder.
|
||||
*/
|
||||
final class StickyFolderHeaders extends LayerUI<JScrollPane> {
|
||||
private static final long serialVersionUID = -2205474187531254372L;
|
||||
private static final int SHADOW_HEIGHT = 4;
|
||||
private static final Color SHADOW = new ThemeColor(new Color(0, 0, 0, 28), new Color(0, 0, 0, 90));
|
||||
private static final Color NO_SHADOW = new Color(0, 0, 0, 0);
|
||||
// The folder handle sits one indent to the left of the folder's name, as the tree draws it.
|
||||
private static final int HANDLE_NUDGE = 1;
|
||||
|
||||
private final JTree tree;
|
||||
private final CellRendererPane rendererPane = new CellRendererPane();
|
||||
private transient JLayer<JScrollPane> layer;
|
||||
private int hoveredHeader = -1;
|
||||
|
||||
private StickyFolderHeaders(JTree tree) {
|
||||
this.tree = tree;
|
||||
}
|
||||
|
||||
static JLayer<JScrollPane> around(JTree tree, JScrollPane scrollPane) {
|
||||
// Copying the scrolled pixels would drag the pinned headers along, so every scroll repaints instead.
|
||||
scrollPane.getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
|
||||
StickyFolderHeaders headers = new StickyFolderHeaders(tree);
|
||||
JLayer<JScrollPane> layer = new JLayer<>(scrollPane, headers);
|
||||
scrollPane.getViewport().addChangeListener(event -> layer.repaint());
|
||||
return layer;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void installUI(JComponent component) {
|
||||
super.installUI(component);
|
||||
layer = (JLayer<JScrollPane>) component;
|
||||
layer.setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK);
|
||||
tree.add(rendererPane);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uninstallUI(JComponent component) {
|
||||
layer.setLayerEventMask(0);
|
||||
tree.remove(rendererPane);
|
||||
layer = null;
|
||||
super.uninstallUI(component);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void paint(Graphics graphics, JComponent component) {
|
||||
super.paint(graphics, component);
|
||||
List<Header> headers = pinnedHeaders();
|
||||
if (headers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
Rectangle viewport = viewportBounds();
|
||||
canvas.clipRect(viewport.x, viewport.y, viewport.width, viewport.height);
|
||||
for (int index = 0; index < headers.size(); index++) {
|
||||
paintHeader(canvas, headers.get(index), index == hoveredHeader, viewport);
|
||||
}
|
||||
Header last = headers.get(headers.size() - 1);
|
||||
int shadowTop = viewport.y + last.top() + tree.getRowHeight();
|
||||
canvas.setPaint(new GradientPaint(0, shadowTop, SHADOW, 0, shadowTop + StudioTheme.scale(SHADOW_HEIGHT), NO_SHADOW));
|
||||
canvas.fillRect(viewport.x, shadowTop, viewport.width, StudioTheme.scale(SHADOW_HEIGHT));
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void paintHeader(Graphics2D canvas, Header header, boolean hovered, Rectangle viewport) {
|
||||
int rowHeight = tree.getRowHeight();
|
||||
int top = viewport.y + header.top();
|
||||
Rectangle row = tree.getRowBounds(header.row());
|
||||
int left = viewport.x + row.x - scrolledView().x;
|
||||
canvas.setColor(hovered ? StudioTheme.HOVER_BACKGROUND : tree.getBackground());
|
||||
canvas.fillRect(viewport.x, top, viewport.width, rowHeight);
|
||||
paintFolderHandle(canvas, left, top + rowHeight / 2);
|
||||
Component renderer = tree.getCellRenderer().getTreeCellRendererComponent(
|
||||
tree, header.path().getLastPathComponent(), false, true, false, header.row(), false);
|
||||
rendererPane.paintComponent(canvas, renderer, tree, left, top, viewport.x + viewport.width - left, rowHeight, true);
|
||||
}
|
||||
|
||||
private void paintFolderHandle(Graphics2D canvas, int folderLeft, int middleY) {
|
||||
Icon handle = UIManager.getIcon("Tree.expandedIcon");
|
||||
if (handle == null || !(tree.getUI() instanceof BasicTreeUI treeUI)) {
|
||||
return;
|
||||
}
|
||||
int middleX = folderLeft - treeUI.getRightChildIndent() + HANDLE_NUDGE;
|
||||
handle.paintIcon(tree, canvas, middleX - handle.getIconWidth() / 2, middleY - handle.getIconHeight() / 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processMouseEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
|
||||
if (event.getID() == MouseEvent.MOUSE_EXITED) {
|
||||
hover(-1);
|
||||
return;
|
||||
}
|
||||
int index = headerAt(event);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
event.consume();
|
||||
if (event.getID() == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(event)) {
|
||||
jumpTo(pinnedHeaders().get(index), index);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processMouseMotionEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
|
||||
hover(headerAt(event));
|
||||
}
|
||||
|
||||
private void hover(int index) {
|
||||
if (index == hoveredHeader) {
|
||||
return;
|
||||
}
|
||||
hoveredHeader = index;
|
||||
tree.setCursor(index >= 0 ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
|
||||
layer.repaint();
|
||||
}
|
||||
|
||||
// The folder lands just below the headers of the folders around it.
|
||||
private void jumpTo(Header header, int level) {
|
||||
Rectangle row = tree.getRowBounds(header.row());
|
||||
JViewport viewport = layer.getView().getViewport();
|
||||
int top = Math.max(0, row.y - level * tree.getRowHeight());
|
||||
int bottomLimit = Math.max(0, tree.getHeight() - viewport.getExtentSize().height);
|
||||
viewport.setViewPosition(new Point(viewport.getViewPosition().x, Math.min(top, bottomLimit)));
|
||||
}
|
||||
|
||||
private int headerAt(MouseEvent event) {
|
||||
if (layer == null || event.getComponent() == null) {
|
||||
return -1;
|
||||
}
|
||||
Point point = SwingUtilities.convertPoint(event.getComponent(), event.getPoint(), layer);
|
||||
Rectangle viewport = viewportBounds();
|
||||
if (!viewport.contains(point)) {
|
||||
return -1;
|
||||
}
|
||||
List<Header> headers = pinnedHeaders();
|
||||
for (int index = headers.size() - 1; index >= 0; index--) {
|
||||
int top = viewport.y + headers.get(index).top();
|
||||
if (point.y >= top && point.y < top + tree.getRowHeight()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each depth, the folder holding the row just under the headers above it, as long as that folder's own row
|
||||
* has scrolled out of sight.
|
||||
*/
|
||||
private List<Header> pinnedHeaders() {
|
||||
List<Header> headers = new ArrayList<>();
|
||||
int rowHeight = tree.getRowHeight();
|
||||
if (layer == null || rowHeight <= 0 || tree.getRowCount() == 0) {
|
||||
return headers;
|
||||
}
|
||||
Rectangle view = scrolledView();
|
||||
for (int level = 0; ; level++) {
|
||||
int slotTop = level * rowHeight;
|
||||
int row = tree.getClosestRowForLocation(view.x, view.y + slotTop);
|
||||
TreePath path = row < 0 ? null : tree.getPathForRow(row);
|
||||
if (path == null || !isInsideFolderAt(path, level)) {
|
||||
return headers;
|
||||
}
|
||||
TreePath folder = ancestorAt(path, level);
|
||||
Rectangle lastRow = tree.getRowBounds(lastRowInside(folder, row));
|
||||
int top = Math.min(slotTop, lastRow.y + lastRow.height - view.y - rowHeight);
|
||||
headers.add(new Header(folder, tree.getRowForPath(folder), top));
|
||||
if (top < slotTop) {
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hidden root is the path's first element, so the folder at depth 0 is its second.
|
||||
private static boolean isInsideFolderAt(TreePath path, int level) {
|
||||
return path.getPathCount() > level + 2;
|
||||
}
|
||||
|
||||
private static TreePath ancestorAt(TreePath path, int level) {
|
||||
TreePath ancestor = path;
|
||||
while (ancestor.getPathCount() > level + 2) {
|
||||
ancestor = ancestor.getParentPath();
|
||||
}
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
// A folder's rows sit together, so the last one is found by halving the rows after a row known to be inside.
|
||||
private int lastRowInside(TreePath folder, int rowInside) {
|
||||
int low = rowInside;
|
||||
int high = tree.getRowCount() - 1;
|
||||
while (low < high) {
|
||||
int middle = (low + high + 1) >>> 1;
|
||||
if (folder.isDescendant(tree.getPathForRow(middle))) {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private Rectangle scrolledView() {
|
||||
return layer.getView().getViewport().getViewRect();
|
||||
}
|
||||
|
||||
private Rectangle viewportBounds() {
|
||||
JViewport viewport = layer.getView().getViewport();
|
||||
Point origin = SwingUtilities.convertPoint(viewport, 0, 0, layer);
|
||||
return new Rectangle(origin.x, origin.y, viewport.getWidth(), viewport.getHeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned folder, its row in the tree and how far below the top of the visible rows it is drawn.
|
||||
*/
|
||||
private record Header(TreePath path, int row, int top) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.Shape;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
|
||||
/**
|
||||
* Draws the floating cards (legends, hover details, hints) on top of a map, styled like IntelliJ's popups.
|
||||
*/
|
||||
final class StudioOverlay {
|
||||
private static final Color FOOTER_BACKGROUND = new ThemeColor(0xF7F8FA, 0x27292C);
|
||||
|
||||
private StudioOverlay() {
|
||||
}
|
||||
|
||||
static void drawCard(Graphics2D canvas, int x, int y, int width, int height) {
|
||||
drawCard(canvas, x, y, width, height, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a card whose bottom {@code footerHeight} pixels are a darker bar, like the shortcut bar of IntelliJ's popups.
|
||||
*/
|
||||
static void drawCard(Graphics2D canvas, int x, int y, int width, int height, int footerHeight) {
|
||||
Graphics2D card = (Graphics2D) canvas.create();
|
||||
card.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
RoundRectangle2D outline = new RoundRectangle2D.Double(x + 0.5D, y + 0.5D, width - 1D, height - 1D,
|
||||
StudioTheme.scale(StudioTheme.OVERLAY_ARC), StudioTheme.scale(StudioTheme.OVERLAY_ARC));
|
||||
card.setColor(StudioTheme.OVERLAY_BACKGROUND);
|
||||
card.fill(outline);
|
||||
if (footerHeight > 0) {
|
||||
int footerTop = y + height - footerHeight;
|
||||
Shape cardClip = card.getClip();
|
||||
card.clip(outline);
|
||||
card.setColor(FOOTER_BACKGROUND);
|
||||
card.fillRect(x, footerTop, width, footerHeight);
|
||||
card.setColor(StudioTheme.OVERLAY_BORDER);
|
||||
card.fillRect(x, footerTop, width, 1);
|
||||
card.setClip(cardClip);
|
||||
}
|
||||
card.setColor(StudioTheme.OVERLAY_BORDER);
|
||||
card.draw(outline);
|
||||
card.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JProgressBar;
|
||||
import java.awt.Dimension;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* An IntelliJ style status bar: what is shown on the left, work in progress in the middle and where the pointer is on the right.
|
||||
*/
|
||||
final class StudioStatusBar extends JPanel {
|
||||
private static final long serialVersionUID = 6302468216735125611L;
|
||||
private static final int HEIGHT = 26;
|
||||
private static final int PROGRESS_STEPS = 1_000;
|
||||
|
||||
private final JLabel summary = statusLabel();
|
||||
private final JLabel activity = statusLabel();
|
||||
private final JProgressBar progress = new JProgressBar(0, PROGRESS_STEPS);
|
||||
private final JLabel position = statusLabel();
|
||||
|
||||
StudioStatusBar() {
|
||||
setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(0, 10, 0, 10)
|
||||
));
|
||||
setPreferredSize(StudioTheme.scaledSize(0, HEIGHT));
|
||||
progress.setMaximumSize(StudioTheme.scaledSize(120, 4));
|
||||
progress.setPreferredSize(StudioTheme.scaledSize(120, 4));
|
||||
progress.setVisible(false);
|
||||
allowShrinking(summary);
|
||||
allowShrinking(activity);
|
||||
add(summary);
|
||||
add(Box.createHorizontalGlue());
|
||||
add(activity);
|
||||
add(Box.createHorizontalStrut(StudioTheme.scale(8)));
|
||||
add(progress);
|
||||
add(Box.createHorizontalStrut(StudioTheme.scale(18)));
|
||||
add(position);
|
||||
}
|
||||
|
||||
void showSummary(String text) {
|
||||
setTextIfChanged(summary, text);
|
||||
summary.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
}
|
||||
|
||||
void showError(String text) {
|
||||
setTextIfChanged(summary, text);
|
||||
summary.setForeground(StudioTheme.ERROR);
|
||||
}
|
||||
|
||||
void showActivity(String text) {
|
||||
setTextIfChanged(activity, text);
|
||||
}
|
||||
|
||||
void showProgress(double completion) {
|
||||
boolean working = completion < 1D;
|
||||
progress.setVisible(working);
|
||||
if (working) {
|
||||
progress.setValue((int) Math.round(Math.max(0D, completion) * PROGRESS_STEPS));
|
||||
}
|
||||
}
|
||||
|
||||
void showPosition(String text) {
|
||||
setTextIfChanged(position, text);
|
||||
}
|
||||
|
||||
// Long text gives way with an ellipsis instead of pushing the other parts of the bar out of view.
|
||||
private static void allowShrinking(JLabel label) {
|
||||
label.setMinimumSize(new Dimension(0, 0));
|
||||
}
|
||||
|
||||
private static JLabel statusLabel() {
|
||||
JLabel label = new JLabel();
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
return label;
|
||||
}
|
||||
|
||||
// Status text changes on every mouse move, and an unchanged label shouldn't trigger a relayout.
|
||||
private static void setTextIfChanged(JLabel label, String text) {
|
||||
String next = text == null ? "" : text;
|
||||
if (!Objects.equals(label.getText(), next)) {
|
||||
label.setText(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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.formdev.flatlaf.FlatDarkLaf;
|
||||
import com.formdev.flatlaf.FlatLaf;
|
||||
import com.formdev.flatlaf.FlatLightLaf;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.IrisSettings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.border.Border;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.Font;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The colours of IntelliJ's "New UI", light and dark, shared by FlatLaf's controls and everything the studio windows
|
||||
* draw themselves. The theme follows the system unless the user picked light or dark.
|
||||
*/
|
||||
final class StudioTheme {
|
||||
static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22);
|
||||
static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30);
|
||||
static final Color HOVER_BACKGROUND = new ThemeColor(0xDFE1E5, 0x393B40);
|
||||
static final Color INACTIVE_SELECTION = new ThemeColor(0xDFE1E5, 0x43454A);
|
||||
static final Color SELECTION = new ThemeColor(0xD4E2FF, 0x2E436E);
|
||||
static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157);
|
||||
static final Color SEPARATOR = new ThemeColor(0xEBECF0, 0x1E1F22);
|
||||
static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5);
|
||||
static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8);
|
||||
static final Color MUTED_TEXT = new ThemeColor(0x818594, 0x6F737A);
|
||||
static final Color ACCENT = new Color(0x3574F0);
|
||||
static final Color LINK = new ThemeColor(0x315FBD, 0x548AF7);
|
||||
static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464);
|
||||
static final Color WARNING = new ThemeColor(0xC27D04, 0xF2C55C);
|
||||
static final Color SUCCESS = new ThemeColor(0x208A3C, 0x5FB865);
|
||||
static final Color OVERLAY_BACKGROUND = new ThemeColor(new Color(0xFF, 0xFF, 0xFF, 240), new Color(0x2B, 0x2D, 0x30, 236));
|
||||
static final Color OVERLAY_BORDER = new ThemeColor(0xDFE1E5, 0x43454A);
|
||||
static final int OVERLAY_ARC = 10;
|
||||
|
||||
private static final boolean DARK_WITHOUT_SYSTEM_PREFERENCE = true;
|
||||
private static final AtomicBoolean INSTALLED = new AtomicBoolean();
|
||||
private static final ExecutorService SYSTEM_CHECK = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Theme Check");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private static volatile boolean dark = DARK_WITHOUT_SYSTEM_PREFERENCE;
|
||||
private static volatile float pixelScale = 1F;
|
||||
private static final Map<String, String> SHARED_DEFAULTS = Map.ofEntries(
|
||||
Map.entry("Component.focusWidth", "1"),
|
||||
Map.entry("Component.innerFocusWidth", "0"),
|
||||
Map.entry("Component.arc", "8"),
|
||||
Map.entry("Button.arc", "8"),
|
||||
Map.entry("TextComponent.arc", "8"),
|
||||
Map.entry("List.selectionArc", "6"),
|
||||
Map.entry("List.selectionInsets", "0,6,0,6"),
|
||||
Map.entry("Tree.selectionArc", "6"),
|
||||
Map.entry("Tree.selectionInsets", "0,6,0,6"),
|
||||
Map.entry("Tree.border", "0,8,0,0"),
|
||||
Map.entry("ScrollBar.width", "10"),
|
||||
Map.entry("ScrollBar.thumbArc", "999"),
|
||||
Map.entry("ScrollBar.thumbInsets", "2,2,2,2"),
|
||||
Map.entry("ScrollBar.showButtons", "false"),
|
||||
Map.entry("ScrollPane.border", "0,0,0,0"),
|
||||
Map.entry("SplitPane.dividerSize", "1"),
|
||||
Map.entry("SplitPaneDivider.style", "plain"),
|
||||
Map.entry("ProgressBar.arc", "999"),
|
||||
Map.entry("ProgressBar.horizontalSize", "120,4")
|
||||
);
|
||||
private static final Map<String, String> DARK_DEFAULTS = withShared(Map.ofEntries(
|
||||
Map.entry("@background", "#2B2D30"),
|
||||
Map.entry("@foreground", "#DFE1E5"),
|
||||
Map.entry("@accentColor", "#3574F0"),
|
||||
Map.entry("@selectionBackground", "#2E436E"),
|
||||
Map.entry("@selectionInactiveBackground", "#43454A"),
|
||||
Map.entry("@disabledForeground", "#6F737A"),
|
||||
Map.entry("Component.borderColor", "#4E5157"),
|
||||
Map.entry("Component.focusedBorderColor", "#3574F0"),
|
||||
Map.entry("Button.toolbar.hoverBackground", "#393B40"),
|
||||
Map.entry("Button.toolbar.pressedBackground", "#43454A"),
|
||||
Map.entry("Button.toolbar.selectedBackground", "#43454A"),
|
||||
Map.entry("ToggleButton.toolbar.selectedBackground", "#43454A"),
|
||||
Map.entry("ScrollBar.track", "#2B2D30"),
|
||||
Map.entry("SplitPane.background", "#1E1F22"),
|
||||
Map.entry("Separator.foreground", "#1E1F22"),
|
||||
Map.entry("ToolTip.background", "#393B40")
|
||||
));
|
||||
private static final Map<String, String> LIGHT_DEFAULTS = withShared(Map.ofEntries(
|
||||
Map.entry("@background", "#F7F8FA"),
|
||||
Map.entry("@foreground", "#000000"),
|
||||
Map.entry("@accentColor", "#3574F0"),
|
||||
Map.entry("@selectionBackground", "#D4E2FF"),
|
||||
Map.entry("@selectionForeground", "#000000"),
|
||||
Map.entry("@selectionInactiveBackground", "#DFE1E5"),
|
||||
Map.entry("@selectionInactiveForeground", "#000000"),
|
||||
Map.entry("@disabledForeground", "#A8ADBD"),
|
||||
Map.entry("Component.borderColor", "#C9CCD6"),
|
||||
Map.entry("Component.focusedBorderColor", "#3574F0"),
|
||||
Map.entry("Button.toolbar.hoverBackground", "#DFE1E5"),
|
||||
Map.entry("Button.toolbar.pressedBackground", "#C9CCD6"),
|
||||
Map.entry("Button.toolbar.selectedBackground", "#DFE1E5"),
|
||||
Map.entry("ToggleButton.toolbar.selectedBackground", "#DFE1E5"),
|
||||
Map.entry("ScrollBar.track", "#F7F8FA"),
|
||||
Map.entry("SplitPane.background", "#EBECF0"),
|
||||
Map.entry("Separator.foreground", "#EBECF0")
|
||||
));
|
||||
|
||||
private StudioTheme() {
|
||||
}
|
||||
|
||||
static void install() {
|
||||
if (!INSTALLED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyLookAndFeel(darkFor(mode()));
|
||||
pixelScale = UIScale.getUserScaleFactor();
|
||||
} catch (Throwable error) {
|
||||
Iris.reportError(error);
|
||||
Iris.warn("Could not apply the Iris studio theme, the studio windows will use Java's default look.");
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isDark() {
|
||||
return dark;
|
||||
}
|
||||
|
||||
static Mode mode() {
|
||||
return Mode.fromSetting(IrisSettings.get().getGui().getTheme());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the user's choice and switches every studio window to it.
|
||||
*/
|
||||
static void choose(Mode next) {
|
||||
IrisSettings settings = IrisSettings.get();
|
||||
settings.getGui().setTheme(next.settingName());
|
||||
settings.forceSave();
|
||||
if (next == Mode.AUTO) {
|
||||
followSystem();
|
||||
return;
|
||||
}
|
||||
switchTo(next == Mode.DARK);
|
||||
}
|
||||
|
||||
// Studio windows call this when they come to the front, so a change made in the system's settings shows up.
|
||||
static void followSystem() {
|
||||
if (!INSTALLED.get() || mode() != Mode.AUTO) {
|
||||
return;
|
||||
}
|
||||
SYSTEM_CHECK.execute(() -> {
|
||||
boolean systemDark = SystemAppearance.prefersDark().orElse(DARK_WITHOUT_SYSTEM_PREFERENCE);
|
||||
EventQueue.invokeLater(() -> switchTo(systemDark));
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean darkFor(Mode chosen) {
|
||||
return switch (chosen) {
|
||||
case LIGHT -> false;
|
||||
case DARK -> true;
|
||||
case AUTO -> SystemAppearance.prefersDark().orElse(DARK_WITHOUT_SYSTEM_PREFERENCE);
|
||||
};
|
||||
}
|
||||
|
||||
private static void switchTo(boolean nextDark) {
|
||||
if (nextDark == dark) {
|
||||
return;
|
||||
}
|
||||
applyLookAndFeel(nextDark);
|
||||
FlatLaf.updateUI();
|
||||
}
|
||||
|
||||
private static void applyLookAndFeel(boolean nextDark) {
|
||||
dark = nextDark;
|
||||
FlatLaf.setGlobalExtraDefaults(nextDark ? DARK_DEFAULTS : LIGHT_DEFAULTS);
|
||||
if (nextDark) {
|
||||
FlatDarkLaf.setup();
|
||||
} else {
|
||||
FlatLightLaf.setup();
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> withShared(Map<String, String> themeDefaults) {
|
||||
Map<String, String> merged = new HashMap<>(SHARED_DEFAULTS);
|
||||
merged.putAll(themeDefaults);
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlatLaf grows fonts and its own controls to the screen's DPI; every size the studio sets itself has to grow with them.
|
||||
*/
|
||||
static int scale(int pixels) {
|
||||
return Math.round(pixels * pixelScale);
|
||||
}
|
||||
|
||||
static double scale(double pixels) {
|
||||
return pixels * pixelScale;
|
||||
}
|
||||
|
||||
static Dimension scaledSize(int width, int height) {
|
||||
return new Dimension(scale(width), scale(height));
|
||||
}
|
||||
|
||||
static Border emptyBorder(int top, int left, int bottom, int right) {
|
||||
return BorderFactory.createEmptyBorder(scale(top), scale(left), scale(bottom), scale(right));
|
||||
}
|
||||
|
||||
static Font font() {
|
||||
return themeFont("defaultFont", Font.PLAIN, 13);
|
||||
}
|
||||
|
||||
static Font smallFont() {
|
||||
return themeFont("small.font", Font.PLAIN, 11);
|
||||
}
|
||||
|
||||
static Font boldFont() {
|
||||
return font().deriveFont(Font.BOLD);
|
||||
}
|
||||
|
||||
static Font titleFont() {
|
||||
return font().deriveFont(Font.BOLD, font().getSize2D() + 1F);
|
||||
}
|
||||
|
||||
static Font sectionFont() {
|
||||
return smallFont().deriveFont(Font.BOLD);
|
||||
}
|
||||
|
||||
static Font monospacedFont() {
|
||||
return themeFont("monospaced.font", Font.PLAIN, 12);
|
||||
}
|
||||
|
||||
private static Font themeFont(String key, int fallbackStyle, int fallbackSize) {
|
||||
Font font = UIManager.getFont(key);
|
||||
return font != null ? font : new Font(Font.SANS_SERIF, fallbackStyle, fallbackSize);
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
AUTO("Match system"),
|
||||
LIGHT("Light"),
|
||||
DARK("Dark");
|
||||
|
||||
private final String label;
|
||||
|
||||
Mode(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
String settingName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static Mode fromSetting(String setting) {
|
||||
for (Mode mode : values()) {
|
||||
if (mode.settingName().equalsIgnoreCase(setting)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return AUTO;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.formdev.flatlaf.FlatClientProperties;
|
||||
import com.formdev.flatlaf.icons.FlatSearchIcon;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JRadioButtonMenuItem;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/**
|
||||
* Builds the IntelliJ style controls the studio windows share: a flat main toolbar, borderless toolbar buttons and search boxes.
|
||||
*/
|
||||
final class StudioToolbar {
|
||||
private static final int TOOLBAR_HEIGHT_PADDING = 5;
|
||||
|
||||
private StudioToolbar() {
|
||||
}
|
||||
|
||||
static JToolBar create() {
|
||||
JToolBar toolbar = new JToolBar();
|
||||
toolbar.setFloatable(false);
|
||||
toolbar.setRollover(true);
|
||||
toolbar.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
toolbar.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(TOOLBAR_HEIGHT_PADDING, 8, TOOLBAR_HEIGHT_PADDING, 8)
|
||||
));
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
static JLabel label(String text) {
|
||||
JLabel label = new JLabel(text);
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
label.setBorder(StudioTheme.emptyBorder(0, 6, 0, 6));
|
||||
return label;
|
||||
}
|
||||
|
||||
static JButton button(String text, String tooltip, Runnable action) {
|
||||
JButton button = toolbarButton(text, tooltip);
|
||||
button.addActionListener(event -> action.run());
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu to pick light, dark or the system's theme.
|
||||
*/
|
||||
static JButton themeButton() {
|
||||
JButton button = toolbarButton("Theme", "Light, dark, or whatever the system uses");
|
||||
button.addActionListener(event -> themeMenu().show(button, 0, button.getHeight()));
|
||||
return button;
|
||||
}
|
||||
|
||||
private static JPopupMenu themeMenu() {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
ButtonGroup choices = new ButtonGroup();
|
||||
StudioTheme.Mode current = StudioTheme.mode();
|
||||
for (StudioTheme.Mode mode : StudioTheme.Mode.values()) {
|
||||
JRadioButtonMenuItem item = new JRadioButtonMenuItem(mode.label(), mode == current);
|
||||
item.addActionListener(event -> StudioTheme.choose(mode));
|
||||
choices.add(item);
|
||||
menu.add(item);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
private static JButton toolbarButton(String text, String tooltip) {
|
||||
JButton button = new JButton(text);
|
||||
button.setToolTipText(tooltip);
|
||||
button.setFocusable(false);
|
||||
button.setMargin(new Insets(4, 8, 4, 8));
|
||||
button.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON);
|
||||
return button;
|
||||
}
|
||||
|
||||
static JToggleButton toggle(String text, String tooltip) {
|
||||
JToggleButton toggle = new JToggleButton(text);
|
||||
toggle.setToolTipText(tooltip);
|
||||
toggle.setFocusable(false);
|
||||
toggle.setMargin(new Insets(4, 8, 4, 8));
|
||||
toggle.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON);
|
||||
return toggle;
|
||||
}
|
||||
|
||||
// An underlined tab like IntelliJ's tool window tabs.
|
||||
static JToggleButton tab(String text) {
|
||||
JToggleButton tab = new JToggleButton(text);
|
||||
tab.setFocusable(false);
|
||||
tab.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TAB);
|
||||
return tab;
|
||||
}
|
||||
|
||||
static void makeSearchField(JTextField field, String placeholder) {
|
||||
field.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, placeholder);
|
||||
field.putClientProperty(FlatClientProperties.TEXT_FIELD_LEADING_ICON, new FlatSearchIcon());
|
||||
field.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true);
|
||||
field.setToolTipText(placeholder);
|
||||
}
|
||||
|
||||
static JPanel padded(JComponent content, int top, int left, int bottom, int right) {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setOpaque(false);
|
||||
panel.setBorder(StudioTheme.emptyBorder(top, left, bottom, right));
|
||||
panel.add(content, BorderLayout.CENTER);
|
||||
return panel;
|
||||
}
|
||||
|
||||
static JScrollPane scroll(JComponent content) {
|
||||
JScrollPane scrollPane = new JScrollPane(content);
|
||||
scrollPane.setBorder(BorderFactory.createEmptyBorder());
|
||||
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
scrollPane.getVerticalScrollBar().setUnitIncrement(18);
|
||||
return scrollPane;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Asks the operating system whether its user prefers dark or light apps, through the same settings desktop apps
|
||||
* follow: the freedesktop portal or GNOME's setting on Linux, the apps theme on Windows, the appearance on macOS.
|
||||
*/
|
||||
final class SystemAppearance {
|
||||
private static final long COMMAND_TIMEOUT_MILLIS = 2000L;
|
||||
private static final String PORTAL_DARK = "uint32 1";
|
||||
private static final String PORTAL_LIGHT = "uint32 2";
|
||||
|
||||
private SystemAppearance() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty when the system has no preference or could not be asked.
|
||||
*/
|
||||
static Optional<Boolean> prefersDark() {
|
||||
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||
if (os.contains("win")) {
|
||||
return windowsPrefersDark();
|
||||
}
|
||||
if (os.contains("mac")) {
|
||||
return macPrefersDark();
|
||||
}
|
||||
return linuxPrefersDark();
|
||||
}
|
||||
|
||||
private static Optional<Boolean> linuxPrefersDark() {
|
||||
Optional<String> portal = output(List.of("gdbus", "call", "--session", "--dest", "org.freedesktop.portal.Desktop",
|
||||
"--object-path", "/org/freedesktop/portal/desktop", "--method", "org.freedesktop.portal.Settings.ReadOne",
|
||||
"org.freedesktop.appearance", "color-scheme"));
|
||||
if (portal.filter(answer -> answer.contains(PORTAL_DARK)).isPresent()) {
|
||||
return Optional.of(true);
|
||||
}
|
||||
if (portal.filter(answer -> answer.contains(PORTAL_LIGHT)).isPresent()) {
|
||||
return Optional.of(false);
|
||||
}
|
||||
Optional<String> gnome = output(List.of("gsettings", "get", "org.gnome.desktop.interface", "color-scheme"));
|
||||
if (gnome.filter(scheme -> scheme.contains("prefer-dark")).isPresent()) {
|
||||
return Optional.of(true);
|
||||
}
|
||||
if (gnome.filter(scheme -> scheme.contains("prefer-light")).isPresent()) {
|
||||
return Optional.of(false);
|
||||
}
|
||||
return output(List.of("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"))
|
||||
.map(theme -> theme.toLowerCase(Locale.ROOT).contains("dark"));
|
||||
}
|
||||
|
||||
private static Optional<Boolean> windowsPrefersDark() {
|
||||
return output(List.of("reg", "query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v", "AppsUseLightTheme"))
|
||||
.filter(answer -> answer.contains("AppsUseLightTheme"))
|
||||
.map(answer -> answer.trim().endsWith("0x0"));
|
||||
}
|
||||
|
||||
// The setting only exists while macOS is dark, so a failed read means light.
|
||||
private static Optional<Boolean> macPrefersDark() {
|
||||
return Optional.of(output(List.of("defaults", "read", "-g", "AppleInterfaceStyle"))
|
||||
.filter(style -> style.trim().equalsIgnoreCase("dark"))
|
||||
.isPresent());
|
||||
}
|
||||
|
||||
private static Optional<String> output(List<String> command) {
|
||||
try {
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
if (!process.waitFor(COMMAND_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
process.destroyForcibly();
|
||||
return Optional.empty();
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try (InputStream stream = process.getInputStream()) {
|
||||
return Optional.of(new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
} catch (IOException missingCommand) {
|
||||
return Optional.empty();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 java.awt.Color;
|
||||
|
||||
/**
|
||||
* A colour with a light and a dark version that always answers with the one of the current theme, like IntelliJ's
|
||||
* JBColor. Components keep the same colour object across a theme switch and simply paint in the other version.
|
||||
*/
|
||||
final class ThemeColor extends Color {
|
||||
private static final long serialVersionUID = 2286435620386470207L;
|
||||
private final Color light;
|
||||
private final Color dark;
|
||||
|
||||
ThemeColor(int lightRgb, int darkRgb) {
|
||||
this(new Color(lightRgb), new Color(darkRgb));
|
||||
}
|
||||
|
||||
ThemeColor(Color light, Color dark) {
|
||||
super(dark.getRGB(), true);
|
||||
this.light = light;
|
||||
this.dark = dark;
|
||||
}
|
||||
|
||||
// Every other colour query of Color, and Java2D's painting, go through this one.
|
||||
@Override
|
||||
public int getRGB() {
|
||||
return current().getRGB();
|
||||
}
|
||||
|
||||
String toHtml() {
|
||||
return String.format("#%06X", getRGB() & 0xFFFFFF);
|
||||
}
|
||||
|
||||
private Color current() {
|
||||
return StudioTheme.isDark() ? dark : light;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* An affine transform built up like Minecraft's pose stack: each step applies to points before the steps added
|
||||
* earlier. Rotations follow the right-hand rule, the way Minecraft turns model parts.
|
||||
*/
|
||||
final class Transform {
|
||||
static final Transform IDENTITY = new Transform(new double[]{1D, 0D, 0D, 0D, 1D, 0D, 0D, 0D, 1D}, Vec3.ZERO);
|
||||
|
||||
private final double[] matrix;
|
||||
private final Vec3 translation;
|
||||
|
||||
private Transform(double[] matrix, Vec3 translation) {
|
||||
this.matrix = matrix;
|
||||
this.translation = translation;
|
||||
}
|
||||
|
||||
Transform translate(double x, double y, double z) {
|
||||
return then(new Transform(IDENTITY.matrix, new Vec3(x, y, z)));
|
||||
}
|
||||
|
||||
Transform rotateX(double degrees) {
|
||||
double cos = cosine(degrees);
|
||||
double sin = sine(degrees);
|
||||
return then(new Transform(new double[]{1D, 0D, 0D, 0D, cos, -sin, 0D, sin, cos}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
Transform rotateY(double degrees) {
|
||||
double cos = cosine(degrees);
|
||||
double sin = sine(degrees);
|
||||
return then(new Transform(new double[]{cos, 0D, sin, 0D, 1D, 0D, -sin, 0D, cos}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
Transform rotateZ(double degrees) {
|
||||
double cos = cosine(degrees);
|
||||
double sin = sine(degrees);
|
||||
return then(new Transform(new double[]{cos, -sin, 0D, sin, cos, 0D, 0D, 0D, 1D}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
Transform scale(double x, double y, double z) {
|
||||
return then(new Transform(new double[]{x, 0D, 0D, 0D, y, 0D, 0D, 0D, z}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
/**
|
||||
* This transform applied after {@code next}.
|
||||
*/
|
||||
Transform then(Transform next) {
|
||||
double[] product = new double[9];
|
||||
for (int row = 0; row < 3; row++) {
|
||||
for (int column = 0; column < 3; column++) {
|
||||
product[row * 3 + column] = matrix[row * 3] * next.matrix[column]
|
||||
+ matrix[row * 3 + 1] * next.matrix[3 + column]
|
||||
+ matrix[row * 3 + 2] * next.matrix[6 + column];
|
||||
}
|
||||
}
|
||||
return new Transform(product, direction(next.translation).plus(translation));
|
||||
}
|
||||
|
||||
Vec3 point(Vec3 point) {
|
||||
return direction(point).plus(translation);
|
||||
}
|
||||
|
||||
Vec3 direction(Vec3 direction) {
|
||||
return new Vec3(
|
||||
matrix[0] * direction.x() + matrix[1] * direction.y() + matrix[2] * direction.z(),
|
||||
matrix[3] * direction.x() + matrix[4] * direction.y() + matrix[5] * direction.z(),
|
||||
matrix[6] * direction.x() + matrix[7] * direction.y() + matrix[8] * direction.z());
|
||||
}
|
||||
|
||||
// Quarter turns stay exact, so faces turned by a blockstate still line up with the block's sides.
|
||||
private static double cosine(double degrees) {
|
||||
return sine(degrees + 90D);
|
||||
}
|
||||
|
||||
private static double sine(double degrees) {
|
||||
double turned = ((degrees % 360D) + 360D) % 360D;
|
||||
if (turned == 0D || turned == 180D) {
|
||||
return 0D;
|
||||
}
|
||||
if (turned == 90D) {
|
||||
return 1D;
|
||||
}
|
||||
if (turned == 270D) {
|
||||
return -1D;
|
||||
}
|
||||
return Math.sin(Math.toRadians(turned));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* A position or direction in a block, in block units.
|
||||
*/
|
||||
record Vec3(double x, double y, double z) {
|
||||
static final Vec3 ZERO = new Vec3(0D, 0D, 0D);
|
||||
|
||||
Vec3 plus(Vec3 other) {
|
||||
return new Vec3(x + other.x, y + other.y, z + other.z);
|
||||
}
|
||||
|
||||
Vec3 minus(Vec3 other) {
|
||||
return new Vec3(x - other.x, y - other.y, z - other.z);
|
||||
}
|
||||
|
||||
Vec3 times(double factor) {
|
||||
return new Vec3(x * factor, y * factor, z * factor);
|
||||
}
|
||||
|
||||
double component(int axis) {
|
||||
return switch (axis) {
|
||||
case 0 -> x;
|
||||
case 1 -> y;
|
||||
default -> z;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,870 +0,0 @@
|
||||
/*
|
||||
* 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.gui.components.IrisRenderer;
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
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.object.IrisWorld;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
import com.volmit.iris.util.collection.KSet;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.math.BlockPosition;
|
||||
import com.volmit.iris.util.math.M;
|
||||
import com.volmit.iris.util.math.RollingSequence;
|
||||
import com.volmit.iris.util.scheduling.ChronoLatch;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import com.volmit.iris.util.scheduling.O;
|
||||
import com.volmit.iris.util.scheduling.PrecisionStopwatch;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.*;
|
||||
import javax.swing.event.MouseInputListener;
|
||||
import java.awt.*;
|
||||
import java.awt.event.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import static com.volmit.iris.util.data.registry.Attributes.MAX_HEALTH;
|
||||
|
||||
public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener, MouseMotionListener, MouseInputListener {
|
||||
private static final long serialVersionUID = 2094606939770332040L;
|
||||
private final KList<LivingEntity> lastEntities = new KList<>();
|
||||
private final KMap<String, Long> notifications = new KMap<>();
|
||||
private final ChronoLatch centities = new ChronoLatch(1000);
|
||||
private final RollingSequence rs = new RollingSequence(512);
|
||||
private final O<Integer> m = new O<>();
|
||||
private final KMap<BlockPosition, BufferedImage> positions = new KMap<>();
|
||||
private final KMap<BlockPosition, BufferedImage> fastpositions = new KMap<>();
|
||||
private final KSet<BlockPosition> working = new KSet<>();
|
||||
private final KSet<BlockPosition> workingfast = new KSet<>();
|
||||
double tfps = 240D;
|
||||
int ltc = 3;
|
||||
private RenderType currentType = RenderType.BIOME;
|
||||
private boolean help = true;
|
||||
private boolean helpIgnored = false;
|
||||
private boolean shift = false;
|
||||
private Player player = null;
|
||||
private boolean debug = false;
|
||||
private boolean control = false;
|
||||
private boolean eco = false;
|
||||
private boolean lowtile = false;
|
||||
private boolean follow = false;
|
||||
private boolean alt = false;
|
||||
private IrisRenderer renderer;
|
||||
private IrisWorld world;
|
||||
private double velocity = 0;
|
||||
private int lowq = 12;
|
||||
private double scale = 128;
|
||||
private double mscale = 4D;
|
||||
private int w = 0;
|
||||
private int h = 0;
|
||||
private double lx = 0;
|
||||
private double lz = 0;
|
||||
private double ox = 0;
|
||||
private double oz = 0;
|
||||
private double hx = 0;
|
||||
private double hz = 0;
|
||||
private double oxp = 0;
|
||||
private double ozp = 0;
|
||||
private Engine engine;
|
||||
private int tid = 0;
|
||||
private final ExecutorService e = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), r -> {
|
||||
tid++;
|
||||
Thread t = new Thread(r);
|
||||
t.setName("Iris HD Renderer " + tid);
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
t.setUncaughtExceptionHandler((et, e) ->
|
||||
{
|
||||
Iris.info("Exception encountered in " + et.getName());
|
||||
e.printStackTrace();
|
||||
});
|
||||
|
||||
return t;
|
||||
});
|
||||
|
||||
private final ExecutorService eh = Executors.newFixedThreadPool(ltc, r -> {
|
||||
tid++;
|
||||
Thread t = new Thread(r);
|
||||
t.setName("Iris Renderer " + tid);
|
||||
t.setPriority(Thread.NORM_PRIORITY);
|
||||
t.setUncaughtExceptionHandler((et, e) ->
|
||||
{
|
||||
Iris.info("Exception encountered in " + et.getName());
|
||||
e.printStackTrace();
|
||||
});
|
||||
|
||||
return t;
|
||||
});
|
||||
private BufferedImage texture;
|
||||
|
||||
public VisionGUI(JFrame frame) {
|
||||
m.set(8);
|
||||
rs.put(1);
|
||||
addMouseWheelListener(this);
|
||||
addMouseMotionListener(this);
|
||||
addMouseListener(this);
|
||||
frame.addKeyListener(this);
|
||||
J.a(() -> {
|
||||
J.sleep(10000);
|
||||
|
||||
if (!helpIgnored && help) {
|
||||
help = false;
|
||||
}
|
||||
});
|
||||
frame.addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(java.awt.event.WindowEvent windowEvent) {
|
||||
e.shutdown();
|
||||
eh.shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void createAndShowGUI(Engine r, int s, IrisWorld world) {
|
||||
JFrame frame = new JFrame("Vision");
|
||||
VisionGUI nv = new VisionGUI(frame);
|
||||
nv.world = world;
|
||||
nv.engine = r;
|
||||
nv.renderer = new IrisRenderer(r);
|
||||
frame.add(nv);
|
||||
frame.setSize(1440, 820);
|
||||
frame.setVisible(true);
|
||||
File file = Iris.getCached("Iris Icon", "https://raw.githubusercontent.com/VolmitSoftware/Iris/master/icon.png");
|
||||
|
||||
if (file != null) {
|
||||
try {
|
||||
nv.texture = ImageIO.read(file);
|
||||
frame.setIconImage(ImageIO.read(file));
|
||||
} catch (IOException e) {
|
||||
Iris.reportError(e);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void launch(Engine g, int i) {
|
||||
J.a(() ->
|
||||
createAndShowGUI(g, i, g.getWorld()));
|
||||
}
|
||||
|
||||
public boolean updateEngine() {
|
||||
if (engine.isClosed()) {
|
||||
if (world.hasRealWorld()) {
|
||||
try {
|
||||
engine = IrisToolbelt.access(world.realWorld()).getEngine();
|
||||
return !engine.isClosed();
|
||||
} catch (Throwable e) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent e) {
|
||||
Point cp = e.getPoint();
|
||||
lx = (cp.getX());
|
||||
lz = (cp.getY());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
Point cp = e.getPoint();
|
||||
ox += (lx - cp.getX()) * scale;
|
||||
oz += (lz - cp.getY()) * scale;
|
||||
lx = cp.getX();
|
||||
lz = cp.getY();
|
||||
}
|
||||
|
||||
public int getColor(double wx, double wz) {
|
||||
BiFunction<Double, Double, Integer> colorFunction = (d, dx) -> Color.black.getRGB();
|
||||
|
||||
switch (currentType) {
|
||||
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD ->
|
||||
colorFunction = (x, z) -> engine.getComplex().getTrueBiomeStream().get(x, z).getColor(engine, currentType).getRGB();
|
||||
case BIOME_LAND ->
|
||||
colorFunction = (x, z) -> engine.getComplex().getLandBiomeStream().get(x, z).getColor(engine, currentType).getRGB();
|
||||
case BIOME_SEA ->
|
||||
colorFunction = (x, z) -> engine.getComplex().getSeaBiomeStream().get(x, z).getColor(engine, currentType).getRGB();
|
||||
case REGION ->
|
||||
colorFunction = (x, z) -> engine.getComplex().getRegionStream().get(x, z).getColor(engine.getComplex(), currentType).getRGB();
|
||||
case CAVE_LAND ->
|
||||
colorFunction = (x, z) -> engine.getComplex().getCaveBiomeStream().get(x, z).getColor(engine, currentType).getRGB();
|
||||
case HEIGHT ->
|
||||
colorFunction = (x, z) -> Color.getHSBColor(engine.getComplex().getHeightStream().get(x, z).floatValue(), 100, 100).getRGB();
|
||||
}
|
||||
|
||||
return colorFunction.apply(wx, wz);
|
||||
}
|
||||
|
||||
public void notify(String s) {
|
||||
notifications.put(s, M.ms() + 2500);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyTyped(KeyEvent e) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyPressed(KeyEvent e) {
|
||||
if (e.getKeyCode() == KeyEvent.VK_SHIFT) {
|
||||
shift = true;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_CONTROL) {
|
||||
control = true;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_SEMICOLON) {
|
||||
debug = true;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_SLASH) {
|
||||
help = true;
|
||||
helpIgnored = true;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_ALT) {
|
||||
alt = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyReleased(KeyEvent e) {
|
||||
if (e.getKeyCode() == KeyEvent.VK_SEMICOLON) {
|
||||
debug = false;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_SHIFT) {
|
||||
shift = false;
|
||||
}
|
||||
|
||||
if (e.getKeyCode() == KeyEvent.VK_CONTROL) {
|
||||
control = false;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_SLASH) {
|
||||
help = false;
|
||||
helpIgnored = true;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_ALT) {
|
||||
alt = false;
|
||||
}
|
||||
|
||||
// Pushes
|
||||
if (e.getKeyCode() == KeyEvent.VK_F) {
|
||||
follow = !follow;
|
||||
|
||||
if (player != null && follow) {
|
||||
notify("Following " + player.getName() + ". Press F to disable");
|
||||
} else if (follow) {
|
||||
notify("Can't follow, no one is in the world");
|
||||
follow = false;
|
||||
} else {
|
||||
notify("Follow Off");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.getKeyCode() == KeyEvent.VK_R) {
|
||||
dump();
|
||||
notify("Refreshing Chunks");
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.getKeyCode() == KeyEvent.VK_P) {
|
||||
lowtile = !lowtile;
|
||||
dump();
|
||||
notify("Rendering " + (lowtile ? "Low" : "High") + " Quality Tiles");
|
||||
return;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_E) {
|
||||
eco = !eco;
|
||||
dump();
|
||||
notify("Using " + (eco ? "60" : "Uncapped") + " FPS Limit");
|
||||
return;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_EQUALS) {
|
||||
mscale = mscale + ((0.044 * mscale) * -3);
|
||||
mscale = Math.max(mscale, 0.00001);
|
||||
dump();
|
||||
return;
|
||||
}
|
||||
if (e.getKeyCode() == KeyEvent.VK_MINUS) {
|
||||
mscale = mscale + ((0.044 * mscale) * 3);
|
||||
mscale = Math.max(mscale, 0.00001);
|
||||
dump();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.getKeyCode() == KeyEvent.VK_BACK_SLASH) {
|
||||
mscale = 1D;
|
||||
dump();
|
||||
notify("Zoom Reset");
|
||||
return;
|
||||
}
|
||||
|
||||
int currentMode = currentType.ordinal();
|
||||
|
||||
for (RenderType i : RenderType.values()) {
|
||||
if (e.getKeyChar() == String.valueOf(i.ordinal() + 1).charAt(0)) {
|
||||
if (i.ordinal() != currentMode) {
|
||||
currentType = i;
|
||||
dump();
|
||||
notify("Rendering " + Form.capitalizeWords(currentType.name().toLowerCase().replaceAll("\\Q_\\E", " ")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.getKeyCode() == KeyEvent.VK_M) {
|
||||
currentType = RenderType.values()[(currentMode + 1) % RenderType.values().length];
|
||||
notify("Rendering " + Form.capitalizeWords(currentType.name().toLowerCase().replaceAll("\\Q_\\E", " ")));
|
||||
dump();
|
||||
}
|
||||
}
|
||||
|
||||
private void dump() {
|
||||
positions.clear();
|
||||
fastpositions.clear();
|
||||
}
|
||||
|
||||
public BufferedImage getTile(KSet<BlockPosition> fg, int div, int x, int z, O<Integer> m) {
|
||||
BlockPosition key = new BlockPosition((int) mscale, Math.floorDiv(x, div), Math.floorDiv(z, div));
|
||||
fg.add(key);
|
||||
|
||||
if (positions.containsKey(key)) {
|
||||
return positions.get(key);
|
||||
}
|
||||
|
||||
if (fastpositions.containsKey(key)) {
|
||||
if (!working.contains(key) && working.size() < 9) {
|
||||
m.set(m.get() - 1);
|
||||
|
||||
if (m.get() >= 0 && velocity < 50) {
|
||||
working.add(key);
|
||||
double mk = mscale;
|
||||
double mkd = scale;
|
||||
e.submit(() ->
|
||||
{
|
||||
PrecisionStopwatch ps = PrecisionStopwatch.start();
|
||||
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType);
|
||||
rs.put(ps.getMilliseconds());
|
||||
working.remove(key);
|
||||
|
||||
if (mk == mscale && mkd == scale) {
|
||||
positions.put(key, b);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fastpositions.get(key);
|
||||
}
|
||||
|
||||
if (workingfast.contains(key) || workingfast.size() > Runtime.getRuntime().availableProcessors()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
workingfast.add(key);
|
||||
double mk = mscale;
|
||||
double mkd = scale;
|
||||
eh.submit(() ->
|
||||
{
|
||||
PrecisionStopwatch ps = PrecisionStopwatch.start();
|
||||
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType);
|
||||
rs.put(ps.getMilliseconds());
|
||||
workingfast.remove(key);
|
||||
|
||||
if (mk == mscale && mkd == scale) {
|
||||
fastpositions.put(key, b);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
private double getWorldX(double screenX) {
|
||||
return (mscale * screenX) + ((oxp / scale) * mscale);
|
||||
}
|
||||
|
||||
private double getWorldZ(double screenZ) {
|
||||
return (mscale * screenZ) + ((ozp / scale) * mscale);
|
||||
}
|
||||
|
||||
private double getScreenX(double x) {
|
||||
return (x / mscale) - ((oxp / scale));
|
||||
}
|
||||
|
||||
private double getScreenZ(double z) {
|
||||
return (z / mscale) - ((ozp / scale));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void paint(Graphics gx) {
|
||||
|
||||
if (engine.isClosed()) {
|
||||
EventQueue.invokeLater(() -> {
|
||||
try {
|
||||
setVisible(false);
|
||||
} catch (Throwable e) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateEngine()) {
|
||||
dump();
|
||||
}
|
||||
|
||||
if (ox < oxp) {
|
||||
velocity = Math.abs(ox - oxp) * 0.36;
|
||||
oxp -= velocity;
|
||||
}
|
||||
|
||||
if (ox > oxp) {
|
||||
velocity = Math.abs(oxp - ox) * 0.36;
|
||||
oxp += velocity;
|
||||
}
|
||||
|
||||
if (oz < ozp) {
|
||||
velocity = Math.abs(oz - ozp) * 0.36;
|
||||
ozp -= velocity;
|
||||
}
|
||||
|
||||
if (oz > ozp) {
|
||||
velocity = Math.abs(ozp - oz) * 0.36;
|
||||
ozp += velocity;
|
||||
}
|
||||
|
||||
if (lx < hx) {
|
||||
hx -= Math.abs(lx - hx) * 0.36;
|
||||
}
|
||||
|
||||
if (lx > hx) {
|
||||
hx += Math.abs(hx - lx) * 0.36;
|
||||
}
|
||||
|
||||
if (lz < hz) {
|
||||
hz -= Math.abs(lz - hz) * 0.36;
|
||||
}
|
||||
|
||||
if (lz > hz) {
|
||||
hz += Math.abs(hz - lz) * 0.36;
|
||||
}
|
||||
|
||||
if (Math.abs(lx - hx) < 0.5) {
|
||||
hx = lx;
|
||||
}
|
||||
if (Math.abs(lz - hz) < 0.5) {
|
||||
hz = lz;
|
||||
}
|
||||
|
||||
if (centities.flip()) {
|
||||
J.s(() -> {
|
||||
synchronized (lastEntities) {
|
||||
lastEntities.clear();
|
||||
lastEntities.addAll(world.getEntitiesByClass(LivingEntity.class));
|
||||
}
|
||||
});
|
||||
}
|
||||
lowq = Math.max(Math.min((int) M.lerp(8, 28, velocity / 1000D), 28), 8);
|
||||
PrecisionStopwatch p = PrecisionStopwatch.start();
|
||||
Graphics2D g = (Graphics2D) gx;
|
||||
w = getWidth();
|
||||
h = getHeight();
|
||||
double vscale = scale;
|
||||
scale = w / 12D;
|
||||
|
||||
if (scale != vscale) {
|
||||
positions.clear();
|
||||
}
|
||||
|
||||
KSet<BlockPosition> gg = new KSet<>();
|
||||
int iscale = (int) scale;
|
||||
g.setColor(Color.white);
|
||||
g.clearRect(0, 0, w, h);
|
||||
double offsetX = oxp / scale;
|
||||
double offsetZ = ozp / scale;
|
||||
m.set(3);
|
||||
|
||||
for (int r = 0; r < Math.max(w, h); r += iscale) {
|
||||
for (int i = -iscale; i < w + iscale; i += iscale) {
|
||||
for (int j = -iscale; j < h + iscale; j += iscale) {
|
||||
int a = i - (w / 2);
|
||||
int b = j - (h / 2);
|
||||
if (a * a + b * b <= r * r) {
|
||||
int tx = (int) (Math.floor((offsetX + i) / iscale) * iscale);
|
||||
int tz = (int) (Math.floor((offsetZ + j) / iscale) * iscale);
|
||||
BufferedImage t = getTile(gg, iscale, tx, tz, m);
|
||||
|
||||
if (t != null) {
|
||||
int rx = Math.floorMod((int) Math.floor(offsetX), iscale);
|
||||
int rz = Math.floorMod((int) Math.floor(offsetZ), iscale);
|
||||
g.drawImage(t, i - rx, j - rz, iscale, iscale, (img, infoflags, x, y, width, height) -> true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.end();
|
||||
|
||||
for (BlockPosition i : positions.k()) {
|
||||
if (!gg.contains(i)) {
|
||||
positions.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
hanleFollow();
|
||||
renderOverlays(g);
|
||||
|
||||
if (!isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getParent().isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getParent().getParent().isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
J.a(() ->
|
||||
{
|
||||
J.sleep(eco ? 15 : 1);
|
||||
repaint();
|
||||
});
|
||||
}
|
||||
|
||||
private void hanleFollow() {
|
||||
if (follow && player != null) {
|
||||
animateTo(player.getLocation().getX(), player.getLocation().getZ());
|
||||
}
|
||||
}
|
||||
|
||||
private void renderOverlays(Graphics2D g) {
|
||||
renderPlayer(g);
|
||||
|
||||
if (help) {
|
||||
renderOverlayHelp(g);
|
||||
} else if (debug) {
|
||||
renderOverlayDebug(g);
|
||||
}
|
||||
|
||||
renderOverlayLegend(g);
|
||||
|
||||
renderHoverOverlay(g, shift);
|
||||
if (!notifications.isEmpty()) {
|
||||
renderNotification(g);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderOverlayLegend(Graphics2D g) {
|
||||
KList<String> l = new KList<>();
|
||||
l.add("Zoom: " + Form.pc(mscale, 0));
|
||||
l.add("Blocks: " + Form.f((int) mscale * w) + " by " + Form.f((int) mscale * h));
|
||||
l.add("BPP: " + Form.f(mscale, 1));
|
||||
l.add("Render Mode: " + Form.capitalizeWords(currentType.name().toLowerCase().replaceAll("\\Q_\\E", " ")));
|
||||
|
||||
drawCardBR(g, l);
|
||||
}
|
||||
|
||||
private void renderNotification(Graphics2D g) {
|
||||
drawCardCB(g, notifications.k());
|
||||
|
||||
for (String i : notifications.k()) {
|
||||
if (M.ms() > notifications.get(i)) {
|
||||
notifications.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderPlayer(Graphics2D g) {
|
||||
Player b = null;
|
||||
|
||||
for (Player i : world.getPlayers()) {
|
||||
b = i;
|
||||
renderPosition(g, i.getLocation().getX(), i.getLocation().getZ());
|
||||
}
|
||||
|
||||
synchronized (lastEntities) {
|
||||
double dist = Double.MAX_VALUE;
|
||||
LivingEntity h = null;
|
||||
|
||||
for (LivingEntity i : lastEntities) {
|
||||
if (i instanceof Player) {
|
||||
continue;
|
||||
}
|
||||
|
||||
renderMobPosition(g, i, i.getLocation().getX(), i.getLocation().getZ());
|
||||
if (shift) {
|
||||
double d = i.getLocation().distanceSquared(new Location(i.getWorld(), getWorldX(hx), i.getLocation().getY(), getWorldZ(hz)));
|
||||
|
||||
if (d < dist) {
|
||||
dist = d;
|
||||
h = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (h != null && shift) {
|
||||
g.setColor(Color.red);
|
||||
g.fillRoundRect((int) getScreenX(h.getLocation().getX()) - 10, (int) getScreenZ(h.getLocation().getZ()) - 10, 20, 20, 20, 20);
|
||||
KList<String> k = new KList<>();
|
||||
k.add(Form.capitalizeWords(h.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " ")) + h.getEntityId());
|
||||
|
||||
k.add("Pos: " + h.getLocation().getBlockX() + ", " + h.getLocation().getBlockY() + ", " + h.getLocation().getBlockZ());
|
||||
k.add("UUID: " + h.getUniqueId());
|
||||
k.add("HP: " + h.getHealth() + " / " + h.getAttribute(MAX_HEALTH).getValue());
|
||||
|
||||
drawCardTR(g, k);
|
||||
}
|
||||
}
|
||||
|
||||
player = b;
|
||||
}
|
||||
|
||||
private void animateTo(double wx, double wz) {
|
||||
double cx = getWorldX(getWidth() / 2);
|
||||
double cz = getWorldZ(getHeight() / 2);
|
||||
ox += ((wx - cx) / mscale) * scale;
|
||||
oz += ((wz - cz) / mscale) * scale;
|
||||
}
|
||||
|
||||
private void renderPosition(Graphics2D g, double x, double z) {
|
||||
if (texture != null) {
|
||||
g.drawImage(texture, (int) getScreenX(x), (int) getScreenZ(z), 66, 66, (img, infoflags, xx, xy, width, height) -> true);
|
||||
} else {
|
||||
g.setColor(Color.darkGray);
|
||||
g.fillRoundRect((int) getScreenX(x) - 15, (int) getScreenZ(z) - 15, 30, 30, 15, 15);
|
||||
g.setColor(Color.cyan.darker().darker());
|
||||
g.fillRoundRect((int) getScreenX(x) - 10, (int) getScreenZ(z) - 10, 20, 20, 10, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderMobPosition(Graphics2D g, LivingEntity e, double x, double z) {
|
||||
g.setColor(Color.red.darker().darker());
|
||||
g.fillRoundRect((int) getScreenX(x) - 2, (int) getScreenZ(z) - 2, 4, 4, 4, 4);
|
||||
}
|
||||
|
||||
private void renderHoverOverlay(Graphics2D g, boolean detailed) {
|
||||
IrisBiome biome = engine.getComplex().getTrueBiomeStream().get(getWorldX(hx), getWorldZ(hz));
|
||||
IrisRegion region = engine.getComplex().getRegionStream().get(getWorldX(hx), getWorldZ(hz));
|
||||
KList<String> l = new KList<>();
|
||||
l.add("Biome: " + biome.getName());
|
||||
l.add("Region: " + region.getName() + "(" + region.getLoadKey() + ")");
|
||||
l.add("Block " + (int) getWorldX(hx) + ", " + (int) getWorldZ(hz));
|
||||
if (detailed) {
|
||||
l.add("Chunk " + ((int) getWorldX(hx) >> 4) + ", " + ((int) getWorldZ(hz) >> 4));
|
||||
l.add("Region " + (((int) getWorldX(hx) >> 4) >> 5) + ", " + (((int) getWorldZ(hz) >> 4) >> 5));
|
||||
l.add("Key: " + biome.getLoadKey());
|
||||
l.add("File: " + biome.getLoadFile());
|
||||
}
|
||||
|
||||
drawCardAt((float) hx, (float) hz, 0, 0, g, l);
|
||||
}
|
||||
|
||||
private void renderOverlayDebug(Graphics2D g) {
|
||||
KList<String> l = new KList<>();
|
||||
l.add("Velocity: " + (int) velocity);
|
||||
l.add("Center Pos: " + Form.f((int) getWorldX(getWidth() / 2)) + ", " + Form.f((int) getWorldZ(getHeight() / 2)));
|
||||
drawCardBL(g, l);
|
||||
}
|
||||
|
||||
private void renderOverlayHelp(Graphics2D g) {
|
||||
KList<String> l = new KList<>();
|
||||
l.add("/ to show this help screen");
|
||||
l.add("R to repaint the screen");
|
||||
l.add("F to follow first player");
|
||||
l.add("+/- to Change Zoom");
|
||||
l.add("\\ to reset zoom to 1");
|
||||
l.add("M to cycle render modes");
|
||||
l.add("P to toggle Tile Quality Mode");
|
||||
l.add("E to toggle Eco FPS Mode");
|
||||
|
||||
int ff = 0;
|
||||
for (RenderType i : RenderType.values()) {
|
||||
ff++;
|
||||
l.add(ff + " to view " + Form.capitalizeWords(i.name().toLowerCase().replaceAll("\\Q_\\E", " ")));
|
||||
}
|
||||
|
||||
l.add("Shift for additional biome details (at cursor)");
|
||||
l.add("CTRL + Click to teleport to location");
|
||||
l.add("ALT + Click to open biome in VSCode");
|
||||
drawCardTL(g, l);
|
||||
}
|
||||
|
||||
private void drawCardTL(Graphics2D g, KList<String> text) {
|
||||
drawCardAt(0, 0, 0, 0, g, text);
|
||||
}
|
||||
|
||||
private void drawCardBR(Graphics2D g, KList<String> text) {
|
||||
drawCardAt(getWidth(), getHeight(), 1, 1, g, text);
|
||||
}
|
||||
|
||||
private void drawCardBL(Graphics2D g, KList<String> text) {
|
||||
drawCardAt(0, getHeight(), 0, 1, g, text);
|
||||
}
|
||||
|
||||
private void drawCardTR(Graphics2D g, KList<String> text) {
|
||||
drawCardAt(getWidth(), 0, 1, 0, g, text);
|
||||
}
|
||||
|
||||
private void open() {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
File r = null;
|
||||
switch (currentType) {
|
||||
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT ->
|
||||
r = complex.getTrueBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode();
|
||||
case BIOME_LAND -> r = complex.getLandBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode();
|
||||
case BIOME_SEA -> r = complex.getSeaBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode();
|
||||
case REGION -> r = complex.getRegionStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode();
|
||||
case CAVE_LAND -> r = complex.getCaveBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode();
|
||||
}
|
||||
|
||||
notify("Opening " + r.getPath() + " in VSCode");
|
||||
}
|
||||
|
||||
private void teleport() {
|
||||
J.s(() -> {
|
||||
if (player != null) {
|
||||
int xx = (int) getWorldX(hx);
|
||||
int zz = (int) getWorldZ(hz);
|
||||
int h = engine.getComplex().getRoundedHeighteightStream().get(xx, zz);
|
||||
player.teleport(new Location(player.getWorld(), xx, h, zz));
|
||||
notify("Teleporting to " + xx + ", " + h + ", " + zz);
|
||||
} else {
|
||||
notify("No player in world, can't teleport.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void drawCardCB(Graphics2D g, KList<String> text) {
|
||||
drawCardAt(getWidth() / 2, getHeight(), 0.5, 1, g, text);
|
||||
}
|
||||
|
||||
private void drawCardCT(Graphics2D g, KList<String> text) {
|
||||
drawCardAt(getWidth() / 2, 0, 0.5, 0, g, text);
|
||||
}
|
||||
|
||||
private void drawCardAt(float x, float y, double pushX, double pushZ, Graphics2D g, KList<String> text) {
|
||||
g.setFont(new Font("Hevetica", Font.BOLD, 16));
|
||||
int h = 0;
|
||||
int w = 0;
|
||||
|
||||
for (String i : text) {
|
||||
h += g.getFontMetrics().getHeight();
|
||||
w = Math.max(w, g.getFontMetrics().stringWidth(i));
|
||||
}
|
||||
|
||||
w += 28;
|
||||
h += 14;
|
||||
|
||||
int cw = (int) ((w + 26) * pushX);
|
||||
int ch = (int) ((h + 26) * pushZ);
|
||||
|
||||
g.setColor(Color.darkGray);
|
||||
g.fillRect((int) x + 7 + 2 - cw, (int) y + 12 + 2 - ch, w + 7, h); // Shadow
|
||||
g.setColor(Color.gray);
|
||||
g.fillRect((int) x + 7 + 1 - cw, (int) y + 12 + 1 - ch, w + 7, h); // Shadow
|
||||
g.setColor(Color.white);
|
||||
g.fillRect((int) x + 7 - cw, (int) y + 12 - ch, w + 7, h);
|
||||
|
||||
g.setColor(Color.black);
|
||||
int m = 0;
|
||||
for (String i : text) {
|
||||
g.drawString(i, x + 14 - cw, y + 14 - ch + (++m * g.getFontMetrics().getHeight()));
|
||||
}
|
||||
}
|
||||
|
||||
public void mouseWheelMoved(MouseWheelEvent e) {
|
||||
int notches = e.getWheelRotation();
|
||||
if (e.isControlDown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double m0 = mscale;
|
||||
double m1 = m0 + ((0.25 * m0) * notches);
|
||||
m1 = Math.max(m1, 0.00001);
|
||||
if (m1 == m0) {
|
||||
return;
|
||||
}
|
||||
|
||||
positions.clear();
|
||||
fastpositions.clear();
|
||||
|
||||
Point p = e.getPoint();
|
||||
double sx = p.getX();
|
||||
double sz = p.getY();
|
||||
|
||||
double newOxp = scale * ((m0 / m1) * (sx + (oxp / scale)) - sx);
|
||||
double newOzp = scale * ((m0 / m1) * (sz + (ozp / scale)) - sz);
|
||||
|
||||
mscale = m1;
|
||||
oxp = newOxp;
|
||||
ozp = newOzp;
|
||||
ox = oxp;
|
||||
oz = ozp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent e) {
|
||||
if (control) {
|
||||
teleport();
|
||||
} else if (alt) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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.gui.components.MapLayer;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.engine.object.IrisWorld;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static com.volmit.iris.util.data.registry.Attributes.MAX_HEALTH;
|
||||
|
||||
final class VisionOverlay {
|
||||
private final Engine engine;
|
||||
private final UUID openerId;
|
||||
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
||||
private volatile List<GuiMarker> playerMarkers = List.of();
|
||||
|
||||
VisionOverlay(Engine engine, UUID openerId) {
|
||||
this.engine = engine;
|
||||
this.openerId = openerId;
|
||||
}
|
||||
|
||||
List<GuiMarker> lastKnownPlayers() {
|
||||
queuePlayerRefresh();
|
||||
return playerMarkers;
|
||||
}
|
||||
|
||||
void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||
runOnServerThread(() -> sink.accept(collectEntities()));
|
||||
}
|
||||
|
||||
void teleport(int blockX, int blockZ) {
|
||||
runOnServerThread(() -> teleportOpener(blockX, blockZ));
|
||||
}
|
||||
|
||||
Optional<String> openInEditor(double worldX, double worldZ, MapLayer layer) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
File file = switch (layer.type()) {
|
||||
case BIOME, HEIGHT ->
|
||||
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
|
||||
case REGION_PREVIEW -> openPreviewedBiome(complex, layer.previewRegionKey(), worldX, worldZ);
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case CONTINENT -> null;
|
||||
};
|
||||
return Optional.ofNullable(file).map(File::getName);
|
||||
}
|
||||
|
||||
private File openPreviewedBiome(IrisComplex complex, String regionKey, double worldX, double worldZ) {
|
||||
IrisRegion region = engine.getData().getRegionLoader().load(regionKey);
|
||||
return region == null ? null : complex.surfaceBiomeAsIfIn(region, worldX, worldZ).openInVSCode();
|
||||
}
|
||||
|
||||
private void queuePlayerRefresh() {
|
||||
if (!playerRefreshQueued.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean scheduled = runOnServerThread(() -> {
|
||||
try {
|
||||
playerMarkers = collectPlayers();
|
||||
} finally {
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private List<GuiMarker> collectPlayers() {
|
||||
List<GuiMarker> markers = new ArrayList<>();
|
||||
for (Player player : engine.getWorld().getPlayers()) {
|
||||
Location at = player.getLocation();
|
||||
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ()));
|
||||
}
|
||||
return List.copyOf(markers);
|
||||
}
|
||||
|
||||
private List<GuiMarker> collectEntities() {
|
||||
IrisWorld world = engine.getWorld();
|
||||
if (!world.hasRealWorld()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<GuiMarker> markers = new ArrayList<>();
|
||||
for (LivingEntity entity : world.getEntitiesByClass(LivingEntity.class)) {
|
||||
if (!(entity instanceof Player)) {
|
||||
markers.add(marker(entity));
|
||||
}
|
||||
}
|
||||
return List.copyOf(markers);
|
||||
}
|
||||
|
||||
private static GuiMarker marker(LivingEntity entity) {
|
||||
Location at = entity.getLocation();
|
||||
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '));
|
||||
double maxHealth = 0D;
|
||||
try {
|
||||
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
|
||||
}
|
||||
|
||||
private void teleportOpener(int blockX, int blockZ) {
|
||||
IrisWorld target = engine.getWorld();
|
||||
Player player = selectPlayer(target.getPlayers());
|
||||
if (player == null || !target.hasRealWorld() || engine.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
World world = target.realWorld();
|
||||
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
|
||||
Location destination = new Location(world, blockX + 0.5D, blockY, blockZ + 0.5D);
|
||||
player.teleportAsync(destination).whenComplete((success, failure) -> {
|
||||
if (failure != null) {
|
||||
Iris.reportError(failure);
|
||||
Iris.warn("Vision could not teleport %s to %d, %d.", player.getName(), blockX, blockZ);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Player selectPlayer(List<Player> players) {
|
||||
if (openerId == null) {
|
||||
return players.isEmpty() ? null : players.get(0);
|
||||
}
|
||||
for (Player player : players) {
|
||||
if (openerId.equals(player.getUniqueId())) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean runOnServerThread(Runnable task) {
|
||||
try {
|
||||
J.s(task);
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
/*
|
||||
* 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.gui.components.IrisRenderer;
|
||||
import com.volmit.iris.core.gui.components.MapLabels;
|
||||
import com.volmit.iris.core.gui.components.MapLayer;
|
||||
import com.volmit.iris.core.gui.components.MapTile;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn.
|
||||
* Tiles keep rendering while the view moves, as long as they are still on screen.
|
||||
*/
|
||||
final class VisionRenderController implements AutoCloseable {
|
||||
static final int TILE_PIXELS = 64;
|
||||
static final double MINIMUM_VIEW_BLOCKS_PER_PIXEL = 1D / 8D;
|
||||
static final double MAXIMUM_BLOCKS_PER_PIXEL = 4_096D;
|
||||
|
||||
private static final double MINIMUM_RENDER_BLOCKS_PER_PIXEL = 1D;
|
||||
private static final int MAXIMUM_VISIBLE_TILES = 32_768;
|
||||
private static final int PROBE_QUEUE_CAPACITY = 1;
|
||||
private static final int COARSER_FALLBACK_LEVELS = 6;
|
||||
private static final long CACHE_BYTES = 256L * 1024L * 1024L;
|
||||
|
||||
private final Runnable listener;
|
||||
private final int workerCount;
|
||||
private final ThreadPoolExecutor renderExecutor;
|
||||
private final ThreadPoolExecutor probeExecutor;
|
||||
private final WeightedTileCache cache;
|
||||
private final Set<TileKey> tilesInFlight = new HashSet<>();
|
||||
private final AtomicInteger activeWorkers = new AtomicInteger();
|
||||
private final AtomicLong viewSequence = new AtomicLong();
|
||||
private final AtomicLong probeSequence = new AtomicLong();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private final AtomicBoolean publicationQueued = new AtomicBoolean();
|
||||
private final AtomicBoolean publicationDirty = new AtomicBoolean();
|
||||
private volatile Frame currentFrame;
|
||||
|
||||
VisionRenderController(Runnable listener) {
|
||||
this.listener = Objects.requireNonNull(listener, "listener");
|
||||
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||||
this.workerCount = Math.max(1, Math.min(6, processors / 2));
|
||||
AtomicInteger threadSequence = new AtomicInteger();
|
||||
this.renderExecutor = new ThreadPoolExecutor(
|
||||
workerCount,
|
||||
workerCount,
|
||||
30L,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(),
|
||||
daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence)
|
||||
);
|
||||
this.renderExecutor.allowCoreThreadTimeOut(true);
|
||||
this.probeExecutor = new ThreadPoolExecutor(
|
||||
1,
|
||||
1,
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(PROBE_QUEUE_CAPACITY),
|
||||
daemonFactory("Iris Vision Probe", Thread.MIN_PRIORITY, threadSequence),
|
||||
new ThreadPoolExecutor.DiscardOldestPolicy()
|
||||
);
|
||||
this.cache = new WeightedTileCache(CACHE_BYTES);
|
||||
GuiHost.interruptOnPluginShutdown(renderExecutor);
|
||||
GuiHost.interruptOnPluginShutdown(probeExecutor);
|
||||
}
|
||||
|
||||
// Snapping to powers of two keeps every zoom within a level on the same tiles; nearest keeps tiles within 1.41x of screen size.
|
||||
static double renderBlocksPerPixel(double viewBlocksPerPixel) {
|
||||
double level = Math.pow(2D, Math.round(Math.log(viewBlocksPerPixel) / Math.log(2D)));
|
||||
return Math.max(MINIMUM_RENDER_BLOCKS_PER_PIXEL, Math.min(MAXIMUM_BLOCKS_PER_PIXEL, level));
|
||||
}
|
||||
|
||||
Frame request(RenderSpec spec) {
|
||||
Objects.requireNonNull(spec, "spec");
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("Vision render controller is closed");
|
||||
}
|
||||
double renderScale = renderBlocksPerPixel(spec.blocksPerPixel());
|
||||
List<VisibleTile> tiles = visibleTiles(spec, renderScale);
|
||||
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
|
||||
for (VisibleTile tile : tiles) {
|
||||
tile.setTile(cachedTileServing(tile.key()));
|
||||
}
|
||||
currentFrame = frame;
|
||||
probeSequence.incrementAndGet();
|
||||
probeExecutor.getQueue().clear();
|
||||
startWorkers();
|
||||
publish();
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile from another zoom level covering the given one, to draw while the real tile renders.
|
||||
*/
|
||||
Fallback fallback(Frame frame, VisibleTile tile) {
|
||||
MapTile withoutHeights = cache.get(tile.key().withHeights(false));
|
||||
if (withoutHeights != null) {
|
||||
return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
|
||||
}
|
||||
Fallback coarser = coarserFallback(frame, tile);
|
||||
return coarser != null ? coarser : finerFallback(frame, tile);
|
||||
}
|
||||
|
||||
// A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing.
|
||||
private MapTile cachedTileServing(TileKey key) {
|
||||
MapTile exact = cache.get(key);
|
||||
if (exact != null || key.heights()) {
|
||||
return exact;
|
||||
}
|
||||
return cache.get(key.withHeights(true));
|
||||
}
|
||||
|
||||
Progress progress(Frame frame) {
|
||||
if (frame == null) {
|
||||
return new Progress(0, 0, 0);
|
||||
}
|
||||
int ready = 0;
|
||||
for (VisibleTile tile : frame.tiles()) {
|
||||
if (tile.tile() != null) {
|
||||
ready++;
|
||||
}
|
||||
}
|
||||
return new Progress(frame.tiles().size(), ready, activeWorkers.get());
|
||||
}
|
||||
|
||||
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
|
||||
Objects.requireNonNull(probe, "probe");
|
||||
Objects.requireNonNull(consumer, "consumer");
|
||||
if (frame == null || closed.get() || currentFrame != frame) {
|
||||
return;
|
||||
}
|
||||
long probeRevision = probeSequence.incrementAndGet();
|
||||
probeExecutor.getQueue().clear();
|
||||
try {
|
||||
probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer));
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
probeExecutor.getQueue().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
currentFrame = null;
|
||||
renderExecutor.shutdownNow();
|
||||
probeExecutor.shutdownNow();
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
static List<VisibleTile> visibleTiles(RenderSpec spec, double renderScale) {
|
||||
Objects.requireNonNull(spec, "spec");
|
||||
double blocksPerPixel = spec.blocksPerPixel();
|
||||
double tileSpan = TILE_PIXELS * renderScale;
|
||||
double halfWidth = spec.width() * blocksPerPixel * 0.5D;
|
||||
double halfHeight = spec.height() * blocksPerPixel * 0.5D;
|
||||
long minimumX = floorTile(spec.centerX() - halfWidth, tileSpan);
|
||||
long maximumX = floorTile(Math.nextDown(spec.centerX() + halfWidth), tileSpan);
|
||||
long minimumZ = floorTile(spec.centerZ() - halfHeight, tileSpan);
|
||||
long maximumZ = floorTile(Math.nextDown(spec.centerZ() + halfHeight), tileSpan);
|
||||
long tileCount = Math.multiplyExact(maximumX - minimumX + 1L, maximumZ - minimumZ + 1L);
|
||||
if (tileCount > MAXIMUM_VISIBLE_TILES) {
|
||||
throw new IllegalArgumentException("Vision viewport contains too many tiles");
|
||||
}
|
||||
|
||||
ArrayList<VisibleTile> tiles = new ArrayList<>((int) tileCount);
|
||||
double centerTileX = spec.centerX() / tileSpan;
|
||||
double centerTileZ = spec.centerZ() / tileSpan;
|
||||
for (long tileZ = minimumZ; tileZ <= maximumZ; tileZ++) {
|
||||
for (long tileX = minimumX; tileX <= maximumX; tileX++) {
|
||||
double deltaX = tileX + 0.5D - centerTileX;
|
||||
double deltaZ = tileZ + 0.5D - centerTileZ;
|
||||
TileKey key = new TileKey(spec.contentRevision(), spec.layer(), spec.heights(), renderScale, tileX, tileZ);
|
||||
tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ));
|
||||
}
|
||||
}
|
||||
tiles.sort(Comparator.comparingDouble(VisibleTile::distanceSquared)
|
||||
.thenComparingLong(VisibleTile::tileZ)
|
||||
.thenComparingLong(VisibleTile::tileX));
|
||||
return List.copyOf(tiles);
|
||||
}
|
||||
|
||||
private static long floorTile(double coordinate, double tileSpan) {
|
||||
double tile = Math.floor(coordinate / tileSpan);
|
||||
if (tile < Long.MIN_VALUE || tile > Long.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Vision viewport exceeds tile coordinate range");
|
||||
}
|
||||
return (long) tile;
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonFactory(String name, int priority, AtomicInteger sequence) {
|
||||
return (Runnable runnable) -> {
|
||||
Thread thread = new Thread(runnable, name + " " + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(priority);
|
||||
thread.setUncaughtExceptionHandler((Thread failedThread, Throwable error) -> Iris.reportError(error));
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
|
||||
private void startWorkers() {
|
||||
while (!closed.get()) {
|
||||
int active = activeWorkers.get();
|
||||
if (active >= workerCount || !hasWork()) {
|
||||
return;
|
||||
}
|
||||
if (!activeWorkers.compareAndSet(active, active + 1)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
renderExecutor.execute(this::renderUntilIdle);
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
activeWorkers.decrementAndGet();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each worker keeps taking the most central missing tile of whatever frame is current, until the screen is covered.
|
||||
private void renderUntilIdle() {
|
||||
try {
|
||||
TileJob job;
|
||||
while ((job = claimNextTile()) != null) {
|
||||
render(job);
|
||||
}
|
||||
} finally {
|
||||
activeWorkers.decrementAndGet();
|
||||
}
|
||||
startWorkers();
|
||||
}
|
||||
|
||||
private synchronized boolean hasWork() {
|
||||
Frame frame = currentFrame;
|
||||
return frame != null && frame.nextMissing(tilesInFlight, false) != null;
|
||||
}
|
||||
|
||||
private synchronized TileJob claimNextTile() {
|
||||
Frame frame = currentFrame;
|
||||
if (frame == null || closed.get()) {
|
||||
return null;
|
||||
}
|
||||
VisibleTile tile = frame.nextMissing(tilesInFlight, true);
|
||||
if (tile == null) {
|
||||
return null;
|
||||
}
|
||||
tilesInFlight.add(tile.key());
|
||||
return new TileJob(frame.spec().renderer(), tile.key());
|
||||
}
|
||||
|
||||
private synchronized void release(TileKey key) {
|
||||
tilesInFlight.remove(key);
|
||||
}
|
||||
|
||||
private void render(TileJob job) {
|
||||
TileKey key = job.key();
|
||||
try {
|
||||
double tileSpan = TILE_PIXELS * key.blocksPerPixel();
|
||||
MapTile tile = job.renderer().renderTile(
|
||||
key.tileX() * tileSpan,
|
||||
key.tileZ() * tileSpan,
|
||||
key.blocksPerPixel(),
|
||||
TILE_PIXELS,
|
||||
key.layer(),
|
||||
key.heights(),
|
||||
() -> !isWanted(key)
|
||||
);
|
||||
cache.put(key, tile);
|
||||
Frame frame = currentFrame;
|
||||
VisibleTile visible = frame == null ? null : frame.tile(key);
|
||||
if (visible != null) {
|
||||
visible.setTile(tile);
|
||||
publish();
|
||||
}
|
||||
} catch (CancellationException ignored) {
|
||||
} catch (Throwable error) {
|
||||
Iris.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
|
||||
} finally {
|
||||
release(key);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWanted(TileKey key) {
|
||||
Frame frame = currentFrame;
|
||||
return !closed.get() && frame != null && frame.tile(key) != null;
|
||||
}
|
||||
|
||||
private Fallback coarserFallback(Frame frame, VisibleTile tile) {
|
||||
TileKey key = tile.key();
|
||||
for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
|
||||
long scale = 1L << level;
|
||||
int sourceSize = TILE_PIXELS >> level;
|
||||
double parentScale = key.blocksPerPixel() * scale;
|
||||
if (sourceSize < 1 || parentScale > MAXIMUM_BLOCKS_PER_PIXEL) {
|
||||
return null;
|
||||
}
|
||||
long parentX = Math.floorDiv(key.tileX(), scale);
|
||||
long parentZ = Math.floorDiv(key.tileZ(), scale);
|
||||
MapTile parent = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, parentX,
|
||||
parentZ));
|
||||
if (parent != null) {
|
||||
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
|
||||
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
|
||||
return new Fallback(List.of(new FallbackPiece(parent, sourceX, sourceZ, sourceSize, 0D, 0D, 1D)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Fallback finerFallback(Frame frame, VisibleTile tile) {
|
||||
TileKey key = tile.key();
|
||||
double childScale = key.blocksPerPixel() / 2D;
|
||||
if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
|
||||
return null;
|
||||
}
|
||||
List<FallbackPiece> pieces = new ArrayList<>(4);
|
||||
for (int childZ = 0; childZ < 2; childZ++) {
|
||||
for (int childX = 0; childX < 2; childX++) {
|
||||
MapTile child = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale,
|
||||
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
|
||||
if (child != null) {
|
||||
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pieces.isEmpty() ? null : new Fallback(List.copyOf(pieces));
|
||||
}
|
||||
|
||||
private <T> void runProbe(long probeRevision, Callable<T> probe, Consumer<T> consumer) {
|
||||
try {
|
||||
if (!isProbeCurrent(probeRevision)) {
|
||||
return;
|
||||
}
|
||||
T result = probe.call();
|
||||
if (!isProbeCurrent(probeRevision)) {
|
||||
return;
|
||||
}
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (isProbeCurrent(probeRevision)) {
|
||||
consumer.accept(result);
|
||||
}
|
||||
});
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Throwable error) {
|
||||
Iris.debug("Vision probe failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isProbeCurrent(long probeRevision) {
|
||||
return !closed.get() && probeSequence.get() == probeRevision;
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
publicationDirty.set(true);
|
||||
queuePublication();
|
||||
}
|
||||
|
||||
private void queuePublication() {
|
||||
if (!publicationQueued.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
EventQueue.invokeLater(() -> {
|
||||
try {
|
||||
if (publicationDirty.getAndSet(false) && !closed.get()) {
|
||||
listener.run();
|
||||
}
|
||||
} finally {
|
||||
publicationQueued.set(false);
|
||||
if (publicationDirty.get() && !closed.get()) {
|
||||
queuePublication();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record RenderSpec(
|
||||
IrisRenderer renderer,
|
||||
MapLayer layer,
|
||||
long contentRevision,
|
||||
double centerX,
|
||||
double centerZ,
|
||||
double blocksPerPixel,
|
||||
int width,
|
||||
int height,
|
||||
boolean heights
|
||||
) {
|
||||
RenderSpec {
|
||||
Objects.requireNonNull(renderer, "renderer");
|
||||
Objects.requireNonNull(layer, "layer");
|
||||
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
|
||||
throw new IllegalArgumentException("Vision center must be finite");
|
||||
}
|
||||
if (!Double.isFinite(blocksPerPixel)
|
||||
|| blocksPerPixel < MINIMUM_VIEW_BLOCKS_PER_PIXEL
|
||||
|| blocksPerPixel > MAXIMUM_BLOCKS_PER_PIXEL) {
|
||||
throw new IllegalArgumentException("Vision scale is outside the supported range");
|
||||
}
|
||||
if (width < 1 || height < 1) {
|
||||
throw new IllegalArgumentException("Vision viewport must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final class Frame {
|
||||
private final long viewRevision;
|
||||
private final RenderSpec spec;
|
||||
private final double renderBlocksPerPixel;
|
||||
private final List<VisibleTile> tiles;
|
||||
private final Map<TileKey, VisibleTile> tilesByKey;
|
||||
private int nextCandidate;
|
||||
|
||||
private Frame(long viewRevision, RenderSpec spec, double renderBlocksPerPixel, List<VisibleTile> tiles) {
|
||||
this.viewRevision = viewRevision;
|
||||
this.spec = Objects.requireNonNull(spec, "spec");
|
||||
this.renderBlocksPerPixel = renderBlocksPerPixel;
|
||||
this.tiles = tiles;
|
||||
this.tilesByKey = new HashMap<>(tiles.size() * 2);
|
||||
for (VisibleTile tile : tiles) {
|
||||
tilesByKey.put(tile.key(), tile);
|
||||
}
|
||||
}
|
||||
|
||||
long viewRevision() {
|
||||
return viewRevision;
|
||||
}
|
||||
|
||||
RenderSpec spec() {
|
||||
return spec;
|
||||
}
|
||||
|
||||
double renderBlocksPerPixel() {
|
||||
return renderBlocksPerPixel;
|
||||
}
|
||||
|
||||
List<VisibleTile> tiles() {
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private VisibleTile tile(TileKey key) {
|
||||
return tilesByKey.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile covering a world position together with the pixel of it there, or null when that tile is not drawn yet.
|
||||
*/
|
||||
TilePixel pixelAt(double worldX, double worldZ) {
|
||||
double tileSpan = TILE_PIXELS * renderBlocksPerPixel;
|
||||
long tileX = (long) Math.floor(worldX / tileSpan);
|
||||
long tileZ = (long) Math.floor(worldZ / tileSpan);
|
||||
VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.layer(), spec.heights(),
|
||||
renderBlocksPerPixel, tileX, tileZ));
|
||||
MapTile tile = visible == null ? null : visible.tile();
|
||||
if (tile == null) {
|
||||
return null;
|
||||
}
|
||||
int pixelX = pixelWithin(worldX - tileX * tileSpan);
|
||||
int pixelZ = pixelWithin(worldZ - tileZ * tileSpan);
|
||||
return new TilePixel(tile, pixelX, pixelZ);
|
||||
}
|
||||
|
||||
private int pixelWithin(double offsetBlocks) {
|
||||
return Math.max(0, Math.min(TILE_PIXELS - 1, (int) Math.floor(offsetBlocks / renderBlocksPerPixel)));
|
||||
}
|
||||
|
||||
// Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
|
||||
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance) {
|
||||
for (int index = nextCandidate; index < tiles.size(); index++) {
|
||||
VisibleTile tile = tiles.get(index);
|
||||
if (tile.tile() == null && !inFlight.contains(tile.key())) {
|
||||
if (advance) {
|
||||
nextCandidate = index + 1;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
}
|
||||
if (advance) {
|
||||
nextCandidate = tiles.size();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static final class VisibleTile {
|
||||
private final TileKey key;
|
||||
private final double distanceSquared;
|
||||
private volatile MapTile tile;
|
||||
|
||||
private VisibleTile(TileKey key, double distanceSquared) {
|
||||
this.key = key;
|
||||
this.distanceSquared = distanceSquared;
|
||||
}
|
||||
|
||||
TileKey key() {
|
||||
return key;
|
||||
}
|
||||
|
||||
long tileX() {
|
||||
return key.tileX();
|
||||
}
|
||||
|
||||
long tileZ() {
|
||||
return key.tileZ();
|
||||
}
|
||||
|
||||
double distanceSquared() {
|
||||
return distanceSquared;
|
||||
}
|
||||
|
||||
MapTile tile() {
|
||||
return tile;
|
||||
}
|
||||
|
||||
private void setTile(MapTile tile) {
|
||||
this.tile = tile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param heights whether the tile also carries terrain heights, for shading
|
||||
*/
|
||||
record TileKey(long contentRevision, MapLayer layer, boolean heights, double blocksPerPixel, long tileX, long tileZ) {
|
||||
TileKey {
|
||||
Objects.requireNonNull(layer, "layer");
|
||||
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Vision cache scale must be finite and positive");
|
||||
}
|
||||
}
|
||||
|
||||
TileKey withHeights(boolean nextHeights) {
|
||||
return new TileKey(contentRevision, layer, nextHeights, blocksPerPixel, tileX, tileZ);
|
||||
}
|
||||
}
|
||||
|
||||
record TilePixel(MapTile tile, int pixelX, int pixelZ) {
|
||||
MapLabels.Label subject() {
|
||||
return tile.labels().label(tile.subjectAt(pixelX, pixelZ));
|
||||
}
|
||||
|
||||
MapLabels.Label region() {
|
||||
return tile.labels().label(tile.regionAt(pixelX, pixelZ));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pieces of cached tiles that together cover a missing tile; offsets and size are fractions of the tile.
|
||||
*/
|
||||
record Fallback(List<FallbackPiece> pieces) {
|
||||
}
|
||||
|
||||
record FallbackPiece(MapTile tile, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
|
||||
double size) {
|
||||
}
|
||||
|
||||
record Progress(int total, int ready, int activeWorkers) {
|
||||
double completion() {
|
||||
if (total == 0) {
|
||||
return 1D;
|
||||
}
|
||||
return Math.min(1D, ready / (double) total);
|
||||
}
|
||||
}
|
||||
|
||||
private record TileJob(IrisRenderer renderer, TileKey key) {
|
||||
}
|
||||
|
||||
private static final class WeightedTileCache {
|
||||
private final long maximumBytes;
|
||||
private final LinkedHashMap<TileKey, CacheEntry> entries;
|
||||
private long bytes;
|
||||
|
||||
private WeightedTileCache(long maximumBytes) {
|
||||
this.maximumBytes = maximumBytes;
|
||||
this.entries = new LinkedHashMap<>(128, 0.75F, true);
|
||||
}
|
||||
|
||||
synchronized MapTile get(TileKey key) {
|
||||
CacheEntry entry = entries.get(key);
|
||||
return entry == null ? null : entry.tile();
|
||||
}
|
||||
|
||||
synchronized void put(TileKey key, MapTile tile) {
|
||||
long tileBytes = tile.bytes();
|
||||
CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes));
|
||||
if (previous != null) {
|
||||
bytes -= previous.bytes();
|
||||
}
|
||||
bytes += tileBytes;
|
||||
while (bytes > maximumBytes && !entries.isEmpty()) {
|
||||
Iterator<Map.Entry<TileKey, CacheEntry>> iterator = entries.entrySet().iterator();
|
||||
Map.Entry<TileKey, CacheEntry> eldest = iterator.next();
|
||||
bytes -= eldest.getValue().bytes();
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void clear() {
|
||||
entries.clear();
|
||||
bytes = 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheEntry(MapTile tile, long bytes) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
record VisionViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||
VisionViewport {
|
||||
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
|
||||
throw new IllegalArgumentException("Vision viewport center must be finite");
|
||||
}
|
||||
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Vision viewport scale must be finite and positive");
|
||||
}
|
||||
}
|
||||
|
||||
double worldX(double screenX, int width) {
|
||||
return centerX + (screenX - width * 0.5D) * blocksPerPixel;
|
||||
}
|
||||
|
||||
double worldZ(double screenZ, int height) {
|
||||
return centerZ + (screenZ - height * 0.5D) * blocksPerPixel;
|
||||
}
|
||||
|
||||
VisionViewport zoomAt(
|
||||
double screenX,
|
||||
double screenZ,
|
||||
int width,
|
||||
int height,
|
||||
double factor,
|
||||
double minimumScale,
|
||||
double maximumScale
|
||||
) {
|
||||
if (!Double.isFinite(factor) || factor <= 0D) {
|
||||
throw new IllegalArgumentException("Vision zoom factor must be finite and positive");
|
||||
}
|
||||
double anchorX = worldX(screenX, width);
|
||||
double anchorZ = worldZ(screenZ, height);
|
||||
double nextScale = Math.max(minimumScale, Math.min(maximumScale, blocksPerPixel * factor));
|
||||
return new VisionViewport(
|
||||
anchorX - (screenX - width * 0.5D) * nextScale,
|
||||
anchorZ - (screenZ - height * 0.5D) * nextScale,
|
||||
nextScale
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.sun.jna.platform.linux.LibC;
|
||||
import com.volmit.iris.Iris;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Java does not recognise the non-reparenting Xwayland window managers of compositors such as niri, sway and Hyprland.
|
||||
* It waits for a reparent that never comes and paints only a corner of each window. Java reads this hint from the
|
||||
* native environment once, at the first decorated window, so it is set through libc before Iris opens any window.
|
||||
*/
|
||||
final class XwaylandWindowManagerHint {
|
||||
private static final String NON_REPARENTING_VARIABLE = "_JAVA_AWT_WM_NONREPARENTING";
|
||||
private static final int KEEP_EXISTING_VALUE = 0;
|
||||
private static final List<String> REPARENTING_DESKTOPS = List.of("GNOME", "KDE");
|
||||
private static final AtomicBoolean APPLIED = new AtomicBoolean();
|
||||
|
||||
private XwaylandWindowManagerHint() {
|
||||
}
|
||||
|
||||
static void apply() {
|
||||
if (!APPLIED.compareAndSet(false, true) || !isNeeded(System.getenv(), System.getProperty("os.name", ""))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (LibC.INSTANCE.setenv(NON_REPARENTING_VARIABLE, "1", KEEP_EXISTING_VALUE) != 0) {
|
||||
warnUnapplied();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
warnUnapplied();
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isNeeded(Map<String, String> environment, String osName) {
|
||||
return osName.toLowerCase(Locale.ROOT).contains("linux")
|
||||
&& isSet(environment, "WAYLAND_DISPLAY")
|
||||
&& isSet(environment, "DISPLAY")
|
||||
&& !isSet(environment, NON_REPARENTING_VARIABLE)
|
||||
&& !isReparentingDesktop(environment.get("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
private static boolean isReparentingDesktop(String currentDesktop) {
|
||||
if (currentDesktop == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Arrays.stream(currentDesktop.split(":"))
|
||||
.map(desktop -> desktop.trim().toUpperCase(Locale.ROOT))
|
||||
.anyMatch(REPARENTING_DESKTOPS::contains);
|
||||
}
|
||||
|
||||
private static boolean isSet(Map<String, String> environment, String variable) {
|
||||
String value = environment.get(variable);
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private static void warnUnapplied() {
|
||||
Iris.warn("Iris could not adjust Java for this Wayland desktop, so its windows may stay blank. "
|
||||
+ "Start the server with %s=1 set to fix that.", NON_REPARENTING_VARIABLE);
|
||||
}
|
||||
}
|
||||
@@ -18,68 +18,613 @@
|
||||
|
||||
package com.volmit.iris.core.gui.components;
|
||||
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomeGeneratorLink;
|
||||
import com.volmit.iris.util.interpolation.IrisInterpolation;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.util.stream.ProceduralStream;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.function.BiFunction;
|
||||
import java.awt.Color;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
public class IrisRenderer {
|
||||
private final Engine renderer;
|
||||
/**
|
||||
* Draws map tiles for one generation of the pack: a hotload needs a new renderer, which also starts new labels.
|
||||
*/
|
||||
public final class IrisRenderer {
|
||||
private static final int BLUE = rgb(45, 91, 156);
|
||||
private static final int YELLOW = rgb(211, 164, 67);
|
||||
private static final int GREEN = rgb(78, 137, 83);
|
||||
private static final int DEEP_WATER = rgb(20, 48, 92);
|
||||
private static final int SHALLOW_WATER = rgb(50, 112, 154);
|
||||
private static final int LOWLAND = rgb(78, 128, 76);
|
||||
private static final int HIGHLAND = rgb(151, 139, 92);
|
||||
private static final int ROCK = rgb(126, 119, 112);
|
||||
private static final int SNOW = rgb(226, 230, 232);
|
||||
private static final int UNKNOWN_SUBJECT = rgb(90, 92, 98);
|
||||
private static final int LARGEST_SAMPLE_BLOCK_PIXELS = 16;
|
||||
private static final double LARGEST_SAMPLE_BLOCK_BLOCKS = 64D;
|
||||
private static final double DEFAULT_DEPTH_BELOW_SEA_BLOCKS = 48D;
|
||||
private static final double DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS = 160D;
|
||||
private static final int NOISE_LOW = rgb(5, 5, 5);
|
||||
private static final int NOISE_HIGH = rgb(255, 255, 255);
|
||||
private static final double DARKEST_PICK_SHADE = 0.3D;
|
||||
|
||||
public IrisRenderer(Engine renderer) {
|
||||
this.renderer = renderer;
|
||||
private final Engine engine;
|
||||
private final MapLabels labels = new MapLabels();
|
||||
private final ConcurrentHashMap<LabelColorKey, Integer> labelColors = new ConcurrentHashMap<>();
|
||||
|
||||
public IrisRenderer(Engine engine) {
|
||||
this.engine = Objects.requireNonNull(engine, "engine");
|
||||
}
|
||||
|
||||
public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
|
||||
BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB);
|
||||
BiFunction<Double, Double, Integer> colorFunction = (d, dx) -> Color.black.getRGB();
|
||||
public MapLabels labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
switch (currentType) {
|
||||
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD ->
|
||||
colorFunction = (x, z) -> renderer.getComplex().getTrueBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
|
||||
case BIOME_LAND ->
|
||||
colorFunction = (x, z) -> renderer.getComplex().getLandBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
|
||||
case BIOME_SEA ->
|
||||
colorFunction = (x, z) -> renderer.getComplex().getSeaBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
|
||||
case REGION ->
|
||||
colorFunction = (x, z) -> renderer.getComplex().getRegionStream().get(x, z).getColor(renderer.getComplex(), currentType).getRGB();
|
||||
case CAVE_LAND ->
|
||||
colorFunction = (x, z) -> renderer.getComplex().getCaveBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
|
||||
case HEIGHT ->
|
||||
colorFunction = (x, z) -> Color.getHSBColor(renderer.getComplex().getHeightStream().get(x, z).floatValue(), 100, 100).getRGB();
|
||||
case CONTINENT -> colorFunction = (x, z) -> {
|
||||
IrisBiome b = renderer.getBiome((int) Math.round(x), renderer.getMaxHeight() - 1, (int) Math.round(z));
|
||||
IrisBiomeGeneratorLink g = b.getGenerators().get(0);
|
||||
Color c;
|
||||
if (g.getMax() <= 0) {
|
||||
// Max is below water level, so it is most likely an ocean biome
|
||||
c = Color.BLUE;
|
||||
} else if (g.getMin() < 0) {
|
||||
// Min is below water level, but max is not, so it is most likely a shore biome
|
||||
c = Color.YELLOW;
|
||||
} else {
|
||||
// Both min and max are above water level, so it is most likely a land biome
|
||||
c = Color.GREEN;
|
||||
}
|
||||
return c.getRGB();
|
||||
};
|
||||
/**
|
||||
* Renders the square of {@code size} pixels whose top left pixel samples the given world position.
|
||||
*
|
||||
* @param withHeights also sample terrain heights for shading; the height view always has them
|
||||
*/
|
||||
public MapTile renderTile(
|
||||
double startX,
|
||||
double startZ,
|
||||
double blocksPerPixel,
|
||||
int size,
|
||||
MapLayer layer,
|
||||
boolean withHeights,
|
||||
BooleanSupplier cancelled
|
||||
) {
|
||||
if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Map tile coordinates and scale must be finite");
|
||||
}
|
||||
if (size < 1) {
|
||||
throw new IllegalArgumentException("Map tile size must be positive");
|
||||
}
|
||||
Objects.requireNonNull(layer, "layer");
|
||||
Objects.requireNonNull(cancelled, "cancelled");
|
||||
RenderType type = layer.type();
|
||||
checkCancelled(cancelled);
|
||||
|
||||
TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled);
|
||||
int[] identities = sampleIdentities(grid, layer);
|
||||
double[] heights = withHeights || type == RenderType.HEIGHT ? sampleHeights(grid) : null;
|
||||
short[] subjects = new short[identities.length];
|
||||
short[] regions = new short[identities.length];
|
||||
for (int index = 0; index < identities.length; index++) {
|
||||
subjects[index] = subjectOf(identities[index]);
|
||||
regions[index] = regionOf(identities[index]);
|
||||
}
|
||||
int[] colors = type == RenderType.HEIGHT ? null : tileColors(size, type, subjects);
|
||||
if (layer.display() != PickDisplay.PICKS) {
|
||||
drawPickNoise(colors, grid, size, layer);
|
||||
}
|
||||
MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
|
||||
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a biome or region has on the map in the given view, for legends.
|
||||
*/
|
||||
public int legendColor(MapLabels.Label label, RenderType type) {
|
||||
RenderType colorType = type == RenderType.HEIGHT ? RenderType.BIOME : type;
|
||||
return labelColor(label.id(), colorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Until the map has measured the heights on screen, land colours cover the band most terrain sits in.
|
||||
*/
|
||||
public MapStyle.HeightRange defaultHeightRange() {
|
||||
double seaLevel = fluidHeight();
|
||||
return new MapStyle.HeightRange(Math.max(0D, seaLevel - DEFAULT_DEPTH_BELOW_SEA_BLOCKS), seaLevel,
|
||||
Math.min(engine.getHeight(), seaLevel + DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS));
|
||||
}
|
||||
|
||||
public static int heightColor(double height, MapStyle.HeightRange range) {
|
||||
double seaLevel = range.seaLevel();
|
||||
if (height <= seaLevel) {
|
||||
double waterDepthRange = Math.max(1D, seaLevel - range.lowest());
|
||||
return blend(DEEP_WATER, SHALLOW_WATER, (height - range.lowest()) / waterDepthRange);
|
||||
}
|
||||
|
||||
double x, z;
|
||||
int i, j;
|
||||
for (i = 0; i < resolution; i++) {
|
||||
x = IrisInterpolation.lerp(sx, sx + size, (double) i / (double) (resolution));
|
||||
double landRange = Math.max(1D, range.highest() - seaLevel);
|
||||
double land = clamp((height - seaLevel) / landRange, 0D, 1D);
|
||||
if (land < 0.45D) {
|
||||
return blend(LOWLAND, HIGHLAND, land / 0.45D);
|
||||
}
|
||||
if (land < 0.78D) {
|
||||
return blend(HIGHLAND, ROCK, (land - 0.45D) / 0.33D);
|
||||
}
|
||||
return blend(ROCK, SNOW, (land - 0.78D) / 0.22D);
|
||||
}
|
||||
|
||||
for (j = 0; j < resolution; j++) {
|
||||
z = IrisInterpolation.lerp(sz, sz + size, (double) j / (double) (resolution));
|
||||
image.setRGB(i, j, colorFunction.apply(x, z));
|
||||
private int[] sampleIdentities(TileGrid grid, MapLayer layer) {
|
||||
IdentitySampler sampler = new IdentitySampler(grid, identityShader(layer));
|
||||
sampler.renderAll(sampleBlockPixels(grid.step()));
|
||||
return sampler.identities;
|
||||
}
|
||||
|
||||
private double[] sampleHeights(TileGrid grid) {
|
||||
return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights();
|
||||
}
|
||||
|
||||
// Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes.
|
||||
private static int sampleBlockPixels(double blocksPerPixel) {
|
||||
int largestPixels = (int) Math.floor(LARGEST_SAMPLE_BLOCK_BLOCKS / blocksPerPixel);
|
||||
return Integer.highestOneBit(Math.max(1, Math.min(LARGEST_SAMPLE_BLOCK_PIXELS, largestPixels)));
|
||||
}
|
||||
|
||||
private IdentityShader identityShader(MapLayer layer) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
ProceduralStream<IrisRegion> regionStream = complex.getRegionStream();
|
||||
LabelLookup lookup = new LabelLookup(labels);
|
||||
if (layer.type() == RenderType.REGION) {
|
||||
return (double x, double z) -> {
|
||||
short region = lookup.idOf(regionStream.get(x, z));
|
||||
return identity(region, region);
|
||||
};
|
||||
}
|
||||
if (layer.type() == RenderType.REGION_PREVIEW) {
|
||||
return regionPreviewShader(complex, layer.previewRegionKey(), lookup);
|
||||
}
|
||||
ProceduralStream<IrisBiome> biomeStream = subjectBiomeStream(complex, layer.type());
|
||||
return (double x, double z) -> identity(lookup.idOf(biomeStream.get(x, z)), lookup.idOf(regionStream.get(x, z)));
|
||||
}
|
||||
|
||||
// Every spot is treated as part of the previewed region, so the tile shows the biomes that region places there.
|
||||
private IdentityShader regionPreviewShader(IrisComplex complex, String regionKey, LabelLookup lookup) {
|
||||
IrisRegion region = engine.getData().getRegionLoader().load(regionKey);
|
||||
if (region == null) {
|
||||
return (double x, double z) -> identity(MapLabels.NONE, MapLabels.NONE);
|
||||
}
|
||||
short regionId = lookup.idOf(region);
|
||||
return (double x, double z) -> identity(lookup.idOf(complex.surfaceBiomeAsIfIn(region, x, z)), regionId);
|
||||
}
|
||||
|
||||
private void drawPickNoise(int[] colors, TileGrid grid, int size, MapLayer layer) {
|
||||
PickNoise noise = pickNoise(grid, layer);
|
||||
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
|
||||
checkCancelled(grid.cancelled());
|
||||
double worldZ = grid.worldZ(pixelZ + 1);
|
||||
for (int pixelX = 0; pixelX < size; pixelX++) {
|
||||
int index = pixelZ * size + pixelX;
|
||||
double value = noise.valueAt((pixelZ + 1) * grid.size() + pixelX + 1, grid.worldX(pixelX + 1), worldZ);
|
||||
colors[index] = layer.display() == PickDisplay.NOISE
|
||||
? noise.color(value)
|
||||
: shade(colors[index], DARKEST_PICK_SHADE + (1D - DARKEST_PICK_SHADE) * noise.brightness(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regions and BIOME terrain biomes are picked by splitting a noise by rarity; HYBRID terrain biomes by a height.
|
||||
private PickNoise pickNoise(TileGrid grid, MapLayer layer) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
if (layer.type() == RenderType.REGION) {
|
||||
ProceduralStream<Double> regionNoise = complex.getRegionStyleStream();
|
||||
return new UnitNoise((gridIndex, x, z) -> regionNoise.get(x, z));
|
||||
}
|
||||
if (complex.isHybridTerrain()) {
|
||||
ProceduralStream<Double> placementHeight = complex.getPlacementHeightStream();
|
||||
return new PlacementHeightNoise((gridIndex, x, z) -> placementHeight.get(x, z), fluidHeight(), defaultHeightRange());
|
||||
}
|
||||
if (layer.type() == RenderType.BIOME) {
|
||||
return new UnitNoise(baseBiomePickNoise(complex));
|
||||
}
|
||||
IrisRegion region = engine.getData().getRegionLoader().load(layer.previewRegionKey());
|
||||
if (region == null) {
|
||||
return new UnitNoise((gridIndex, x, z) -> 0D);
|
||||
}
|
||||
return new UnitNoise(biomePickNoise(complex, region, grid));
|
||||
}
|
||||
|
||||
// The biome view shows each spot's base biome, which its own region picks from the biome list of the spot's type.
|
||||
private static NoiseField baseBiomePickNoise(IrisComplex complex) {
|
||||
ProceduralStream<IrisRegion> regions = complex.getRegionStream();
|
||||
ProceduralStream<InferredType> types = complex.getBridgeStream();
|
||||
Map<IrisRegion, Map<InferredType, ProceduralStream<Double>>> noiseByRegion = new IdentityHashMap<>();
|
||||
return (gridIndex, x, z) -> {
|
||||
IrisRegion region = regions.get(x, z);
|
||||
return noiseByRegion.computeIfAbsent(region, subject -> new EnumMap<>(InferredType.class))
|
||||
.computeIfAbsent(types.get(x, z), type -> complex.biomePickNoiseStream(region, type))
|
||||
.get(x, z);
|
||||
};
|
||||
}
|
||||
|
||||
// Finding which biome list picked a spot is slow, so it is sampled in squares like the biomes; the noise is cheap.
|
||||
private static NoiseField biomePickNoise(IrisComplex complex, IrisRegion region, TileGrid grid) {
|
||||
IdentitySampler typeSampler = new IdentitySampler(grid, (x, z) -> complex.surfacePickAsIfIn(region, x, z).type().ordinal());
|
||||
typeSampler.renderAll(sampleBlockPixels(grid.step()));
|
||||
int[] typeByGridIndex = typeSampler.identities;
|
||||
InferredType[] types = InferredType.values();
|
||||
Map<InferredType, ProceduralStream<Double>> noiseByType = new EnumMap<>(InferredType.class);
|
||||
return (gridIndex, x, z) -> noiseByType
|
||||
.computeIfAbsent(types[typeByGridIndex[gridIndex]], type -> complex.biomePickNoiseStream(region, type))
|
||||
.get(x, z);
|
||||
}
|
||||
|
||||
// Hybrid terrain decides land, coast and sea by height after the biomes are chosen, so the final biome tells.
|
||||
private static ProceduralStream<IrisBiome> subjectBiomeStream(IrisComplex complex, RenderType type) {
|
||||
return switch (type) {
|
||||
case BIOME_LAND -> complex.getLandBiomeStream();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream();
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream();
|
||||
case CONTINENT -> complex.isHybridTerrain() ? complex.getTrueBiomeStream() : complex.getBaseBiomeStream();
|
||||
case BIOME, HEIGHT, REGION, REGION_PREVIEW -> complex.getBaseBiomeStream();
|
||||
};
|
||||
}
|
||||
|
||||
private int[] tileColors(int size, RenderType type, short[] subjects) {
|
||||
int[] colors = new int[size * size];
|
||||
short previousSubject = MapLabels.NONE;
|
||||
int previousColor = UNKNOWN_SUBJECT;
|
||||
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
|
||||
for (int pixelX = 0; pixelX < size; pixelX++) {
|
||||
int gridIndex = (pixelZ + 1) * (size + 2) + pixelX + 1;
|
||||
short subject = subjects[gridIndex];
|
||||
if (subject != previousSubject) {
|
||||
previousSubject = subject;
|
||||
previousColor = labelColor(subject, type);
|
||||
}
|
||||
colors[pixelZ * size + pixelX] = previousColor;
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private double fluidHeight() {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
return dimension == null ? 0D : dimension.getFluidHeight();
|
||||
}
|
||||
|
||||
private int labelColor(short id, RenderType type) {
|
||||
MapLabels.Label label = labels.label(id);
|
||||
if (label == null) {
|
||||
return UNKNOWN_SUBJECT;
|
||||
}
|
||||
return labelColors.computeIfAbsent(new LabelColorKey(type, id), key -> subjectColor(label.subject(), type));
|
||||
}
|
||||
|
||||
private int subjectColor(Object subject, RenderType type) {
|
||||
if (type == RenderType.CONTINENT && subject instanceof IrisBiome biome) {
|
||||
return engine.getComplex().isHybridTerrain() ? continentColorByPlacement(biome) : continentColor(biome);
|
||||
}
|
||||
if (subject instanceof IrisRegion region) {
|
||||
return region.getColor(engine.getComplex(), type).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
if (subject instanceof IrisBiome biome) {
|
||||
return biome.getColor(engine, type).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
return UNKNOWN_SUBJECT;
|
||||
}
|
||||
|
||||
private static int continentColor(IrisBiome biome) {
|
||||
List<IrisBiomeGeneratorLink> generators = biome.getGenerators();
|
||||
if (generators.isEmpty()) {
|
||||
return GREEN;
|
||||
}
|
||||
IrisBiomeGeneratorLink generator = generators.get(0);
|
||||
if (generator.getMax() <= 0D) {
|
||||
return BLUE;
|
||||
}
|
||||
if (generator.getMin() < 0D) {
|
||||
return YELLOW;
|
||||
}
|
||||
return GREEN;
|
||||
}
|
||||
|
||||
private static int continentColorByPlacement(IrisBiome biome) {
|
||||
if (biome.isShore()) {
|
||||
return YELLOW;
|
||||
}
|
||||
return biome.isSea() ? BLUE : GREEN;
|
||||
}
|
||||
|
||||
private static int identity(short subject, short region) {
|
||||
return (subject << 16) | (region & 0xFFFF);
|
||||
}
|
||||
|
||||
private static short subjectOf(int identity) {
|
||||
return (short) (identity >> 16);
|
||||
}
|
||||
|
||||
private static short regionOf(int identity) {
|
||||
return (short) identity;
|
||||
}
|
||||
|
||||
private static int rgb(int red, int green, int blue) {
|
||||
return new Color(red, green, blue).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
|
||||
private static int shade(int color, double brightness) {
|
||||
int red = (int) Math.round(((color >> 16) & 0xFF) * brightness);
|
||||
int green = (int) Math.round(((color >> 8) & 0xFF) * brightness);
|
||||
int blue = (int) Math.round((color & 0xFF) * brightness);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
private static int blend(int first, int second, double progress) {
|
||||
double bounded = clamp(progress, 0D, 1D);
|
||||
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded);
|
||||
int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - bounded) + ((second >> 8) & 0xFF) * bounded);
|
||||
int blue = (int) Math.round((first & 0xFF) * (1D - bounded) + (second & 0xFF) * bounded);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
private static double clamp(double value, double minimum, double maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
private static void checkCancelled(BooleanSupplier cancelled) {
|
||||
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
|
||||
throw new CancellationException("Map tile render cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IdentityShader {
|
||||
int identity(double x, double z);
|
||||
}
|
||||
|
||||
private record LabelColorKey(RenderType type, short id) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface NoiseField {
|
||||
double at(int gridIndex, double x, double z);
|
||||
}
|
||||
|
||||
/**
|
||||
* A noise that picks regions or biomes, drawn on its own or as how bright the picks are.
|
||||
*/
|
||||
private interface PickNoise {
|
||||
double valueAt(int gridIndex, double x, double z);
|
||||
|
||||
int color(double value);
|
||||
|
||||
double brightness(double value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A noise whose values run from 0 to 1, split into one band per region or biome by their rarities.
|
||||
*/
|
||||
private record UnitNoise(NoiseField field) implements PickNoise {
|
||||
@Override
|
||||
public double valueAt(int gridIndex, double x, double z) {
|
||||
return field.at(gridIndex, x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int color(double value) {
|
||||
return blend(NOISE_LOW, NOISE_HIGH, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double brightness(double value) {
|
||||
return clamp(value, 0D, 1D);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The height above the fluid that HYBRID terrain picks biomes by, coloured like the height view.
|
||||
*/
|
||||
private record PlacementHeightNoise(NoiseField field, double fluidHeight, MapStyle.HeightRange range) implements PickNoise {
|
||||
@Override
|
||||
public double valueAt(int gridIndex, double x, double z) {
|
||||
return field.at(gridIndex, x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int color(double value) {
|
||||
return heightColor(fluidHeight + value, range);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double brightness(double value) {
|
||||
return clamp((fluidHeight + value - range.lowest()) / Math.max(1D, range.highest() - range.lowest()), 0D, 1D);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The square of world positions one tile samples, including the one pixel ring borrowed from its neighbours.
|
||||
*/
|
||||
private record TileGrid(int size, double startX, double startZ, double step, BooleanSupplier cancelled) {
|
||||
double worldX(int gridX) {
|
||||
return startX + gridX * step;
|
||||
}
|
||||
|
||||
double worldZ(int gridZ) {
|
||||
return startZ + gridZ * step;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels the biomes and regions one tile meets, asking the shared labels only for ones this tile has not seen yet.
|
||||
*/
|
||||
private static final class LabelLookup {
|
||||
private final MapLabels labels;
|
||||
private final IdentityHashMap<Object, Short> seen = new IdentityHashMap<>();
|
||||
|
||||
private LabelLookup(MapLabels labels) {
|
||||
this.labels = labels;
|
||||
}
|
||||
|
||||
short idOf(IrisBiome biome) {
|
||||
if (biome == null) {
|
||||
return MapLabels.NONE;
|
||||
}
|
||||
return seen.computeIfAbsent(biome, subject -> labels.idOf(biome));
|
||||
}
|
||||
|
||||
short idOf(IrisRegion region) {
|
||||
if (region == null) {
|
||||
return MapLabels.NONE;
|
||||
}
|
||||
return seen.computeIfAbsent(region, subject -> labels.idOf(region));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples the biome and region of every grid cell in squares, filling a square without sampling its inside when
|
||||
* its corners and centre agree, and splitting it into quarters otherwise.
|
||||
*/
|
||||
private static final class IdentitySampler {
|
||||
private final TileGrid grid;
|
||||
private final IdentityShader shader;
|
||||
private final int[] identities;
|
||||
private final boolean[] sampled;
|
||||
|
||||
private IdentitySampler(TileGrid grid, IdentityShader shader) {
|
||||
this.grid = grid;
|
||||
this.shader = shader;
|
||||
this.identities = new int[grid.size() * grid.size()];
|
||||
this.sampled = new boolean[identities.length];
|
||||
}
|
||||
|
||||
void renderAll(int blockPixels) {
|
||||
int size = grid.size();
|
||||
for (int gridZ = 0; gridZ < size; gridZ += blockPixels) {
|
||||
int height = Math.min(blockPixels, size - gridZ);
|
||||
for (int gridX = 0; gridX < size; gridX += blockPixels) {
|
||||
render(gridX, gridZ, Math.min(blockPixels, size - gridX), height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
private void render(int gridX, int gridZ, int width, int height) {
|
||||
checkCancelled(grid.cancelled());
|
||||
if (width == 1 && height == 1) {
|
||||
sample(gridX, gridZ);
|
||||
return;
|
||||
}
|
||||
if (fillIfUniform(gridX, gridZ, width, height)) {
|
||||
return;
|
||||
}
|
||||
int leftWidth = Math.max(1, width / 2);
|
||||
int rightWidth = width - leftWidth;
|
||||
int topHeight = Math.max(1, height / 2);
|
||||
int bottomHeight = height - topHeight;
|
||||
render(gridX, gridZ, leftWidth, topHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(gridX + leftWidth, gridZ, rightWidth, topHeight);
|
||||
}
|
||||
if (bottomHeight > 0) {
|
||||
render(gridX, gridZ + topHeight, leftWidth, bottomHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(gridX + leftWidth, gridZ + topHeight, rightWidth, bottomHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean fillIfUniform(int gridX, int gridZ, int width, int height) {
|
||||
int maximumX = gridX + width - 1;
|
||||
int maximumZ = gridZ + height - 1;
|
||||
int identity = sample(gridX, gridZ);
|
||||
boolean uniform = sample(maximumX, gridZ) == identity
|
||||
&& sample(gridX, maximumZ) == identity
|
||||
&& sample(maximumX, maximumZ) == identity
|
||||
&& sample(gridX + width / 2, gridZ + height / 2) == identity;
|
||||
if (!uniform) {
|
||||
return false;
|
||||
}
|
||||
for (int row = gridZ; row <= maximumZ; row++) {
|
||||
int start = row * grid.size() + gridX;
|
||||
Arrays.fill(identities, start, start + width, identity);
|
||||
Arrays.fill(sampled, start, start + width, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int sample(int gridX, int gridZ) {
|
||||
int index = gridZ * grid.size() + gridX;
|
||||
if (!sampled[index]) {
|
||||
identities[index] = shader.identity(grid.worldX(gridX), grid.worldZ(gridZ));
|
||||
sampled[index] = true;
|
||||
}
|
||||
return identities[index];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain heights from samples on a coarse lattice, smoothly interpolated in between. Iris heights are costly to
|
||||
* sample, and shading turns the steps of cheaper shortcuts into visible creases, so a smooth curve through fewer
|
||||
* exact samples looks better and draws an order of magnitude faster than sampling every pixel. The lattice lines
|
||||
* up across tiles, so slopes continue seamlessly from one tile into the next.
|
||||
*/
|
||||
private static final class HeightLattice {
|
||||
private static final int LATTICE_PIXELS = 4;
|
||||
private static final int LATTICE_MARGIN = 2;
|
||||
|
||||
private final TileGrid grid;
|
||||
private final ProceduralStream<Double> stream;
|
||||
private final int pointsPerSide;
|
||||
private final double[] points;
|
||||
|
||||
private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
|
||||
this.grid = grid;
|
||||
this.stream = stream;
|
||||
this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
|
||||
this.points = new double[pointsPerSide * pointsPerSide];
|
||||
}
|
||||
|
||||
double[] interpolatedHeights() {
|
||||
samplePoints();
|
||||
int size = grid.size();
|
||||
double[] heights = new double[size * size];
|
||||
for (int gridZ = 0; gridZ < size; gridZ++) {
|
||||
for (int gridX = 0; gridX < size; gridX++) {
|
||||
heights[gridZ * size + gridX] = heightAt(gridX - 1, gridZ - 1);
|
||||
}
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
|
||||
// Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach.
|
||||
private void samplePoints() {
|
||||
double latticeStep = grid.step() * LATTICE_PIXELS;
|
||||
double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
|
||||
double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
|
||||
for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
|
||||
checkCancelled(grid.cancelled());
|
||||
for (int pointX = 0; pointX < pointsPerSide; pointX++) {
|
||||
points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep,
|
||||
firstZ + pointZ * latticeStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double heightAt(int pixelX, int pixelZ) {
|
||||
int cellX = Math.floorDiv(pixelX, LATTICE_PIXELS);
|
||||
int cellZ = Math.floorDiv(pixelZ, LATTICE_PIXELS);
|
||||
double fractionX = (pixelX - cellX * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
|
||||
double fractionZ = (pixelZ - cellZ * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
|
||||
int baseX = cellX + LATTICE_MARGIN;
|
||||
int baseZ = cellZ + LATTICE_MARGIN;
|
||||
double[] rows = new double[4];
|
||||
for (int row = 0; row < 4; row++) {
|
||||
int offset = (baseZ - 1 + row) * pointsPerSide + baseX - 1;
|
||||
rows[row] = catmullRom(points[offset], points[offset + 1], points[offset + 2], points[offset + 3], fractionX);
|
||||
}
|
||||
return catmullRom(rows[0], rows[1], rows[2], rows[3], fractionZ);
|
||||
}
|
||||
|
||||
private static double catmullRom(double before, double start, double end, double after, double fraction) {
|
||||
double squared = fraction * fraction;
|
||||
double cubed = squared * fraction;
|
||||
return 0.5D * (2D * start
|
||||
+ (end - before) * fraction
|
||||
+ (2D * before - 5D * start + 4D * end - after) * squared
|
||||
+ (3D * start - before - 3D * end + after) * cubed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.components;
|
||||
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Numbers every biome and region the map has drawn, so a tile can remember what each of its pixels shows in two bytes.
|
||||
* One set of labels belongs to one generation of the pack: a hotload starts a new one.
|
||||
*/
|
||||
public final class MapLabels {
|
||||
public static final short NONE = -1;
|
||||
|
||||
private final IdentityHashMap<Object, Short> idsBySubject = new IdentityHashMap<>();
|
||||
private final Map<String, Short> idsByKey = new HashMap<>();
|
||||
private final List<Label> labels = new ArrayList<>();
|
||||
|
||||
synchronized short idOf(IrisBiome biome) {
|
||||
return idOf(biome, Kind.BIOME, biome.getLoadKey(), biome.getName());
|
||||
}
|
||||
|
||||
synchronized short idOf(IrisRegion region) {
|
||||
return idOf(region, Kind.REGION, region.getLoadKey(), region.getName());
|
||||
}
|
||||
|
||||
public synchronized Label label(short id) {
|
||||
return id < 0 || id >= labels.size() ? null : labels.get(id);
|
||||
}
|
||||
|
||||
public synchronized short idOf(Kind kind, String key) {
|
||||
Short id = idsByKey.get(kind.name() + ':' + key);
|
||||
return id == null ? NONE : id;
|
||||
}
|
||||
|
||||
private short idOf(Object subject, Kind kind, String key, String name) {
|
||||
Short existing = idsBySubject.get(subject);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
if (labels.size() >= Short.MAX_VALUE) {
|
||||
return NONE;
|
||||
}
|
||||
short id = (short) labels.size();
|
||||
labels.add(new Label(id, kind, key, name, subject));
|
||||
idsBySubject.put(subject, id);
|
||||
idsByKey.putIfAbsent(kind.name() + ':' + key, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
public enum Kind {
|
||||
BIOME,
|
||||
REGION
|
||||
}
|
||||
|
||||
public record Label(short id, Kind kind, String key, String name, Object subject) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.components;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What a map tile shows: one of the render types, for a region preview which region every spot is treated as, and in
|
||||
* the region and biome views whether the noise that picks is drawn too.
|
||||
*
|
||||
* @param previewRegionKey the region a {@link RenderType#REGION_PREVIEW} tile shows everywhere, null for every other type
|
||||
* @param display how the picking noise is drawn; only the region and biome views draw anything but the picks
|
||||
*/
|
||||
public record MapLayer(RenderType type, String previewRegionKey, PickDisplay display) {
|
||||
public MapLayer {
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(display, "display");
|
||||
if ((type == RenderType.REGION_PREVIEW) != (previewRegionKey != null)) {
|
||||
throw new IllegalArgumentException("Only a region preview names a region");
|
||||
}
|
||||
if (display != PickDisplay.PICKS && !type.drawsPickNoise()) {
|
||||
throw new IllegalArgumentException(type + " has no picking noise to draw");
|
||||
}
|
||||
}
|
||||
|
||||
public static MapLayer of(RenderType type) {
|
||||
return new MapLayer(type, null, PickDisplay.PICKS);
|
||||
}
|
||||
|
||||
public static MapLayer biomes(PickDisplay display) {
|
||||
return new MapLayer(RenderType.BIOME, null, display);
|
||||
}
|
||||
|
||||
public static MapLayer regions(PickDisplay display) {
|
||||
return new MapLayer(RenderType.REGION, null, display);
|
||||
}
|
||||
|
||||
public static MapLayer regionPreview(String regionKey, PickDisplay display) {
|
||||
return new MapLayer(RenderType.REGION_PREVIEW, Objects.requireNonNull(regionKey, "regionKey"), display);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.components;
|
||||
|
||||
/**
|
||||
* How map tiles are drawn on top of their plain colours: terrain shading, region borders and one highlighted biome or region.
|
||||
*
|
||||
* @param highlight the biome or region to keep bright while everything else dims, or null to show all
|
||||
* @param heightRange the heights the height view spreads its colours over, or null outside the height view
|
||||
*/
|
||||
public record MapStyle(boolean shading, boolean regionBorders, Highlight highlight, HeightRange heightRange) {
|
||||
public static final MapStyle PLAIN = new MapStyle(false, false, null, null);
|
||||
|
||||
public MapStyle withShading(boolean nextShading) {
|
||||
return new MapStyle(nextShading, regionBorders, highlight, heightRange);
|
||||
}
|
||||
|
||||
public MapStyle withRegionBorders(boolean nextRegionBorders) {
|
||||
return new MapStyle(shading, nextRegionBorders, highlight, heightRange);
|
||||
}
|
||||
|
||||
public MapStyle withHighlight(Highlight nextHighlight) {
|
||||
return new MapStyle(shading, regionBorders, nextHighlight, heightRange);
|
||||
}
|
||||
|
||||
public MapStyle withHeightRange(HeightRange nextHeightRange) {
|
||||
return new MapStyle(shading, regionBorders, highlight, nextHeightRange);
|
||||
}
|
||||
|
||||
public record Highlight(MapLabels.Kind kind, String key, String name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Water colours run from the lowest height up to sea level, land colours from sea level up to the highest.
|
||||
*/
|
||||
public record HeightRange(double lowest, double seaLevel, double highest) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* 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.components;
|
||||
|
||||
import com.volmit.iris.core.gui.GuiHost;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
/**
|
||||
* One rendered square of the map. Besides its colours it remembers which biome or region and which region every pixel
|
||||
* shows, and optionally the terrain height, so highlighting, borders, shading and the legend never need a new render.
|
||||
* The label and height grids have a one pixel border taken from the neighbouring tiles, so edges and slopes line up
|
||||
* across tile boundaries.
|
||||
*/
|
||||
public final class MapTile {
|
||||
private static final double SHADING_EXAGGERATION = 1.2D;
|
||||
private static final double LIGHT_X = -1D;
|
||||
private static final double LIGHT_Y = 1.5D;
|
||||
private static final double LIGHT_Z = -1D;
|
||||
private static final double LIGHT_LENGTH = Math.sqrt(LIGHT_X * LIGHT_X + LIGHT_Y * LIGHT_Y + LIGHT_Z * LIGHT_Z);
|
||||
private static final double FLAT_LIGHT = LIGHT_Y / LIGHT_LENGTH;
|
||||
private static final double DARKEST_SHADE = 0.55D;
|
||||
private static final double BRIGHTEST_SHADE = 1.25D;
|
||||
private static final int DIM_TARGET = 0x1E1F22;
|
||||
private static final double DIM_AMOUNT = 0.72D;
|
||||
private static final double DIM_DESATURATION = 0.6D;
|
||||
private static final int HIGHLIGHT_EDGE = 0xFFFFFF;
|
||||
// A solid outline turns biomes that are scattered pixel by pixel into white noise, so the edge only tints.
|
||||
private static final double HIGHLIGHT_EDGE_STRENGTH = 0.55D;
|
||||
private static final int REGION_BORDER = 0x101114;
|
||||
private static final double REGION_BORDER_STRENGTH = 0.6D;
|
||||
|
||||
private final int size;
|
||||
private final double blocksPerPixel;
|
||||
private final MapLabels labels;
|
||||
private final MapLabels.Kind subjectKind;
|
||||
private final int[] colors;
|
||||
private final MapStyle.HeightRange defaultHeightRange;
|
||||
private final short[] subjects;
|
||||
private final short[] regions;
|
||||
private final double[] heights;
|
||||
private MapStyle styledFor;
|
||||
private BufferedImage styledImage;
|
||||
|
||||
/**
|
||||
* @param colors the colour of every pixel, or null to colour the pixels by their height when they are drawn
|
||||
* @param defaultHeightRange the height colours to use until the map has measured the heights on screen
|
||||
*/
|
||||
MapTile(int size, double blocksPerPixel, MapLabels labels, MapLabels.Kind subjectKind, int[] colors,
|
||||
MapStyle.HeightRange defaultHeightRange, short[] subjects, short[] regions, double[] heights) {
|
||||
this.size = size;
|
||||
this.blocksPerPixel = blocksPerPixel;
|
||||
this.labels = labels;
|
||||
this.subjectKind = subjectKind;
|
||||
this.colors = colors;
|
||||
this.defaultHeightRange = defaultHeightRange;
|
||||
this.subjects = subjects;
|
||||
this.regions = regions;
|
||||
this.heights = heights;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public MapLabels labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pixels name biomes or, in the region view, regions.
|
||||
*/
|
||||
public MapLabels.Kind subjectKind() {
|
||||
return subjectKind;
|
||||
}
|
||||
|
||||
public boolean hasHeights() {
|
||||
return heights != null;
|
||||
}
|
||||
|
||||
public short subjectAt(int pixelX, int pixelZ) {
|
||||
return subjects[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
public short regionAt(int pixelX, int pixelZ) {
|
||||
return regions[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
public double heightAt(int pixelX, int pixelZ) {
|
||||
return heights[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
public long bytes() {
|
||||
long styledBytes = (long) size * size * Integer.BYTES;
|
||||
long gridCells = (long) (size + 2) * (size + 2);
|
||||
long heightBytes = heights == null ? 0L : gridCells * Double.BYTES;
|
||||
long colorBytes = colors == null ? 0L : (long) colors.length * Integer.BYTES;
|
||||
return colorBytes + gridCells * Short.BYTES * 2L + heightBytes + styledBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile as it looks in the given style; the last style's picture is kept, so redrawing an unchanged map is free.
|
||||
*/
|
||||
public synchronized BufferedImage image(MapStyle style) {
|
||||
if (styledImage == null || !style.equals(styledFor)) {
|
||||
styledImage = GuiHost.uncachedImage(styledPixels(style), size, size);
|
||||
styledFor = style;
|
||||
}
|
||||
return styledImage;
|
||||
}
|
||||
|
||||
private int[] styledPixels(MapStyle style) {
|
||||
int[] pixels = new int[size * size];
|
||||
boolean shading = style.shading() && heights != null;
|
||||
short[] highlightGrid = highlightGrid(style.highlight());
|
||||
short highlighted = highlightGrid == null
|
||||
? MapLabels.NONE
|
||||
: labels.idOf(style.highlight().kind(), style.highlight().key());
|
||||
MapStyle.HeightRange heightRange = style.heightRange() == null ? defaultHeightRange : style.heightRange();
|
||||
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
|
||||
for (int pixelX = 0; pixelX < size; pixelX++) {
|
||||
int color = colors == null
|
||||
? IrisRenderer.heightColor(heightAt(pixelX, pixelZ), heightRange)
|
||||
: colors[pixelZ * size + pixelX];
|
||||
if (shading) {
|
||||
color = shade(color, pixelX, pixelZ);
|
||||
}
|
||||
if (highlightGrid != null) {
|
||||
color = highlight(color, highlightGrid, pixelX, pixelZ, highlighted);
|
||||
}
|
||||
if (style.regionBorders() && isRegionEdge(pixelX, pixelZ)) {
|
||||
color = blend(color, REGION_BORDER, REGION_BORDER_STRENGTH);
|
||||
}
|
||||
pixels[pixelZ * size + pixelX] = color;
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
// Lit from the north west like a relief map, so slopes read as hills and valleys at a glance.
|
||||
private int shade(int color, int pixelX, int pixelZ) {
|
||||
double slopeX = (height(pixelX + 1, pixelZ) - height(pixelX - 1, pixelZ)) / (2D * blocksPerPixel);
|
||||
double slopeZ = (height(pixelX, pixelZ + 1) - height(pixelX, pixelZ - 1)) / (2D * blocksPerPixel);
|
||||
double normalX = -slopeX * SHADING_EXAGGERATION;
|
||||
double normalZ = -slopeZ * SHADING_EXAGGERATION;
|
||||
double normalLength = Math.sqrt(normalX * normalX + 1D + normalZ * normalZ);
|
||||
double light = (normalX * LIGHT_X + LIGHT_Y + normalZ * LIGHT_Z) / (normalLength * LIGHT_LENGTH);
|
||||
double factor = Math.max(DARKEST_SHADE, Math.min(BRIGHTEST_SHADE, light / FLAT_LIGHT));
|
||||
return scale(color, factor);
|
||||
}
|
||||
|
||||
// Every view knows its regions, so a region can be highlighted in a biome view too.
|
||||
private short[] highlightGrid(MapStyle.Highlight highlight) {
|
||||
if (highlight == null) {
|
||||
return null;
|
||||
}
|
||||
if (highlight.kind() == subjectKind) {
|
||||
return subjects;
|
||||
}
|
||||
return highlight.kind() == MapLabels.Kind.REGION ? regions : null;
|
||||
}
|
||||
|
||||
private int highlight(int color, short[] grid, int pixelX, int pixelZ, short highlighted) {
|
||||
if (grid[gridIndex(pixelX, pixelZ)] != highlighted) {
|
||||
return dim(color);
|
||||
}
|
||||
boolean edge = grid[gridIndex(pixelX + 1, pixelZ)] != highlighted
|
||||
|| grid[gridIndex(pixelX - 1, pixelZ)] != highlighted
|
||||
|| grid[gridIndex(pixelX, pixelZ + 1)] != highlighted
|
||||
|| grid[gridIndex(pixelX, pixelZ - 1)] != highlighted;
|
||||
return edge ? blend(color, HIGHLIGHT_EDGE, HIGHLIGHT_EDGE_STRENGTH) : color;
|
||||
}
|
||||
|
||||
private boolean isRegionEdge(int pixelX, int pixelZ) {
|
||||
short region = regionAt(pixelX, pixelZ);
|
||||
return regions[gridIndex(pixelX + 1, pixelZ)] != region || regions[gridIndex(pixelX, pixelZ + 1)] != region;
|
||||
}
|
||||
|
||||
private double height(int pixelX, int pixelZ) {
|
||||
return heights[gridIndex(pixelX, pixelZ)];
|
||||
}
|
||||
|
||||
// Pixel coordinates run from -1 to size, the outer ring being the neighbouring tiles' edge pixels.
|
||||
private int gridIndex(int pixelX, int pixelZ) {
|
||||
return (pixelZ + 1) * (size + 2) + pixelX + 1;
|
||||
}
|
||||
|
||||
private static int dim(int color) {
|
||||
int red = (color >> 16) & 0xFF;
|
||||
int green = (color >> 8) & 0xFF;
|
||||
int blue = color & 0xFF;
|
||||
int gray = (red + green + blue) / 3;
|
||||
int desaturated = blend(color, (gray << 16) | (gray << 8) | gray, DIM_DESATURATION);
|
||||
return blend(desaturated, DIM_TARGET, DIM_AMOUNT);
|
||||
}
|
||||
|
||||
private static int scale(int color, double factor) {
|
||||
int red = Math.min(255, (int) (((color >> 16) & 0xFF) * factor));
|
||||
int green = Math.min(255, (int) (((color >> 8) & 0xFF) * factor));
|
||||
int blue = Math.min(255, (int) ((color & 0xFF) * factor));
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
static int blend(int first, int second, double amount) {
|
||||
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - amount) + ((second >> 16) & 0xFF) * amount);
|
||||
int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - amount) + ((second >> 8) & 0xFF) * amount);
|
||||
int blue = (int) Math.round((first & 0xFF) * (1D - amount) + (second & 0xFF) * amount);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user