diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance index a0ef9d4d8..d330ce7ae 100644 --- a/core/plugins/Iris/cache/instance +++ b/core/plugins/Iris/cache/instance @@ -1 +1 @@ -1771127798 \ No newline at end of file +-2003345818 \ No newline at end of file diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index 5a836960e..20279d0df 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -24,10 +24,9 @@ import com.google.gson.JsonObject; import art.arcane.iris.Iris; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; -import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.datapack.DataVersion; -import art.arcane.iris.core.pregenerator.PregenTask; -import art.arcane.iris.core.pregenerator.methods.RegenPregenMethod; +import art.arcane.iris.core.runtime.ChunkClearer; +import art.arcane.iris.core.runtime.InPlaceChunkRegenerator; import art.arcane.iris.core.service.IrisEngineSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisPackBenchmarking; @@ -49,7 +48,6 @@ import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.io.CountingDataInputStream; import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; -import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.matter.Matter; import art.arcane.iris.util.nbt.common.mca.MCAFile; @@ -58,9 +56,7 @@ import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import lombok.SneakyThrows; import org.bukkit.Bukkit; -import org.bukkit.Chunk; import org.bukkit.World; -import org.bukkit.entity.Player; import java.io.*; import java.net.InetAddress; @@ -294,47 +290,15 @@ public class CommandDeveloper implements DirectorExecutor { return; } - art.arcane.volmlib.util.mantle.runtime.Mantle mantle = access.getEngine().getMantle().getMantle(); int centerX = player().getLocation().getBlockX() >> 4; int centerZ = player().getLocation().getBlockZ() >> 4; - int minY = world.getMinHeight(); - int maxY = world.getMaxHeight(); - int total = (radius * 2 + 1) * (radius * 2 + 1); - int processed = 0; - int failed = 0; + int chunks = (radius * 2 + 1) * (radius * 2 + 1); - for (int x = -radius; x <= radius; x++) { - for (int z = -radius; z <= radius; z++) { - int chunkX = centerX + x; - int chunkZ = centerZ + z; - try { - Chunk chunk = world.getChunkAt(chunkX, chunkZ); - for (org.bukkit.entity.Entity entity : chunk.getEntities()) { - if (!(entity instanceof Player)) { - entity.remove(); - } - } - for (int xx = 0; xx < 16; xx++) { - for (int zz = 0; zz < 16; zz++) { - for (int yy = minY; yy < maxY; yy++) { - chunk.getBlock(xx, yy, zz).setType(org.bukkit.Material.AIR, false); - } - } - } - mantle.deleteChunk(chunkX, chunkZ); - processed++; - } catch (Throwable e) { - failed++; - Iris.reportError(e); - } - } - } + sender().sendMessage(C.GREEN + "Delete started: " + C.GOLD + chunks + C.GREEN + + " chunk(s) around " + C.GOLD + centerX + "," + centerZ + C.GREEN + + ". Clearing blocks to air."); - if (failed == 0) { - sender().sendMessage(C.GREEN + "Deleted blocks in " + C.GOLD + processed + C.GREEN + "/" + C.GOLD + total + C.GREEN + " chunk(s)."); - } else { - sender().sendMessage(C.YELLOW + "Deleted blocks in " + C.GOLD + processed + C.YELLOW + "/" + C.GOLD + total + C.YELLOW + " chunk(s); " + C.RED + failed + C.YELLOW + " failed."); - } + new ChunkClearer(world, access.getEngine(), sender(), centerX, centerZ, radius).start(); } @Director(description = "Test", aliases = {"ip"}) @@ -355,7 +319,7 @@ public class CommandDeveloper implements DirectorExecutor { // --- Regen --- - @Director(name = "regen", aliases = {"rg"}, description = "Regenerate nearby chunks using Iris generation", origin = DirectorOrigin.PLAYER, sync = true) + @Director(name = "regen", aliases = {"rg"}, description = "Delete and regenerate nearby chunks in place using Iris generation", origin = DirectorOrigin.PLAYER, sync = true) public void regen( @Param(name = "radius", description = "The radius of nearby chunks", defaultValue = "5") int radius @@ -371,11 +335,6 @@ public class CommandDeveloper implements DirectorExecutor { return; } - if (INMS.get().isBukkit()) { - sender().sendMessage(C.RED + "Regen requires the native chunk system; it is unavailable in Bukkit fallback mode."); - return; - } - Engine engine = IrisToolbelt.access(world).getEngine(); if (engine == null) { sender().sendMessage(C.RED + "The engine access for this world is null. Generate nearby chunks first."); @@ -385,22 +344,16 @@ public class CommandDeveloper implements DirectorExecutor { int centerX = player().getLocation().getBlockX() >> 4; int centerZ = player().getLocation().getBlockZ() >> 4; int chunks = (radius * 2 + 1) * (radius * 2 + 1); - PregenTask task = PregenTask.builder() - .center(new Position2(centerX << 4, centerZ << 4)) - .radiusX(radius << 4) - .radiusZ(radius << 4) - .gui(false) - .build(); sender().sendMessage(C.GREEN + "Regen started: " + C.GOLD + chunks + C.GREEN + " chunk(s) around " + C.GOLD + centerX + "," + centerZ + C.GREEN - + ". Chunks purge and regenerate through the async pipeline."); + + ". Deleting and regenerating in place."); Iris.info("Regen run start: world=" + world.getName() + " center=" + centerX + "," + centerZ + " radius=" + radius + " chunks=" + chunks); - IrisToolbelt.pregenerate(task, new RegenPregenMethod(world), engine, false); + new InPlaceChunkRegenerator(world, engine, sender(), centerX, centerZ, radius).start(); } } diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 8eee0ee3f..9bdac292e 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -125,8 +125,6 @@ public interface INMSBinding { return false; } - boolean isBukkit(); - int getBiomeId(Biome biome); MCABiomeContainer newBiomeContainer(int min, int max, int[] data); @@ -189,10 +187,6 @@ public interface INMSBinding { return 441; } - default boolean purgeChunk(World world, int x, int z) { - throw new UnsupportedOperationException("The active NMS binding does not support chunk purge (regen)."); - } - boolean missingDimensionTypes(String... keys); default boolean injectBukkit() { diff --git a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java index 91fd301f1..dfacf8a12 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java +++ b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java @@ -117,11 +117,6 @@ public class NMSBinding1X implements INMSBinding { return false; } - @Override - public boolean purgeChunk(World world, int x, int z) { - throw new UnsupportedOperationException("Regen is unavailable: Iris is running in Bukkit fallback mode (NMS disabled). Regen requires the native chunk system."); - } - @Override public KMap> getBlockProperties() { KMap> map = new KMap<>(); @@ -214,11 +209,6 @@ public class NMSBinding1X implements INMSBinding { return biomes; } - @Override - public boolean isBukkit() { - return true; - } - @Override public DataVersion getDataVersion() { return DataVersion.UNSUPPORTED; diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/RegenPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/RegenPregenMethod.java deleted file mode 100644 index 697d48448..000000000 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/RegenPregenMethod.java +++ /dev/null @@ -1,104 +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 . - */ - -package art.arcane.iris.core.pregenerator.methods; - -import art.arcane.iris.core.nms.INMS; -import art.arcane.iris.core.pregenerator.PregenListener; -import art.arcane.iris.core.pregenerator.PregeneratorMethod; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.volmlib.util.mantle.runtime.Mantle; -import org.bukkit.World; - -public class RegenPregenMethod implements PregeneratorMethod { - private final World world; - private final AsyncPregenMethod delegate; - - public RegenPregenMethod(World world) { - this.world = world; - this.delegate = new AsyncPregenMethod(world, 0); - } - - @Override - public void init() { - delegate.init(); - } - - @Override - public void close() { - delegate.close(); - } - - @Override - public void save() { - delegate.save(); - } - - @Override - public boolean supportsRegions(int x, int z, PregenListener listener) { - return delegate.supportsRegions(x, z, listener); - } - - @Override - public String getMethod(int x, int z) { - return "Regen"; - } - - @Override - public boolean isAsyncChunkMode() { - return delegate.isAsyncChunkMode(); - } - - @Override - public void generateRegion(int x, int z, PregenListener listener) { - delegate.generateRegion(x, z, listener); - } - - @Override - public void generateChunk(int x, int z, PregenListener listener) { - purge(x, z); - delegate.generateChunk(x, z, listener); - } - - @Override - public Mantle getMantle() { - return delegate.getMantle(); - } - - private void purge(int x, int z) { - if (!IrisToolbelt.isIrisWorld(world)) { - return; - } - - Engine engine = IrisToolbelt.access(world).getEngine(); - if (engine == null) { - return; - } - - int radius = Math.max(0, engine.getMantle().getRealRadius()); - Mantle mantle = engine.getMantle().getMantle(); - for (int dx = -radius; dx <= radius; dx++) { - for (int dz = -radius; dz <= radius; dz++) { - mantle.deleteChunk(x + dx, z + dz); - } - } - - INMS.get().purgeChunk(world, x, z); - } -} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java b/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java new file mode 100644 index 000000000..baa5eef14 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java @@ -0,0 +1,137 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.runtime; + +import art.arcane.iris.Iris; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; + +public final class ChunkClearer { + private static final int APPLY_AHEAD = 8; + + private final World world; + private final Engine engine; + private final int centerChunkX; + private final int centerChunkZ; + private final int radius; + private final ChunkJobReporter reporter; + + public ChunkClearer(World world, Engine engine, VolmitSender sender, int centerChunkX, int centerChunkZ, int radius) { + this.world = world; + this.engine = engine; + this.centerChunkX = centerChunkX; + this.centerChunkZ = centerChunkZ; + this.radius = Math.max(0, radius); + this.reporter = new ChunkJobReporter(sender, "Delete", world); + } + + public void start() { + reporter.start(); + Thread thread = new Thread(this::run, "Iris Delete Chunks"); + thread.setDaemon(true); + thread.start(); + } + + private void run() { + boolean error = false; + try { + List targets = ChunkJobReporter.orderedTargets(centerChunkX, centerChunkZ, radius); + reporter.setTotal(targets.size()); + reporter.setStage("Clearing"); + clear(targets); + } catch (Throwable e) { + Iris.reportError(e); + error = true; + } finally { + reporter.finish(error); + } + } + + private void clear(List targets) throws InterruptedException { + Mantle mantle = engine.getMantle().getMantle(); + Semaphore inFlight = new Semaphore(APPLY_AHEAD); + CountDownLatch allCleared = new CountDownLatch(targets.size()); + + for (int[] target : targets) { + int chunkX = target[0]; + int chunkZ = target[1]; + + inFlight.acquire(); + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + boolean ok = false; + try { + clearChunk(chunkX, chunkZ, mantle); + ok = true; + } catch (Throwable e) { + Iris.reportError(e); + } finally { + reporter.countApplied(ok); + inFlight.release(); + allCleared.countDown(); + } + }); + + if (!scheduled) { + Iris.warn("Delete could not schedule chunk clear at " + chunkX + "," + chunkZ + " in " + world.getName() + "."); + reporter.countApplied(false); + inFlight.release(); + allCleared.countDown(); + } + } + + allCleared.await(); + } + + private void clearChunk(int chunkX, int chunkZ, Mantle mantle) { + Chunk chunk = world.getChunkAt(chunkX, chunkZ); + for (Entity entity : chunk.getEntities()) { + if (!(entity instanceof Player)) { + entity.remove(); + } + } + + int minHeight = world.getMinHeight(); + int maxHeight = world.getMaxHeight(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minHeight; y < maxHeight; y++) { + Block live = chunk.getBlock(x, y, z); + if (live.getType() != Material.AIR) { + live.setType(Material.AIR, false); + } + } + } + } + + mantle.deleteChunk(chunkX, chunkZ); + world.refreshChunk(chunkX, chunkZ); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java b/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java new file mode 100644 index 000000000..bcca06087 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java @@ -0,0 +1,187 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.runtime; + +import art.arcane.iris.Iris; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.format.Form; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public final class ChunkJobReporter { + private static final int PROGRESS_BAR_WIDTH = 24; + private static final int REPORT_INTERVAL_TICKS = 5; + private static final int FINISH_LINGER_TICKS = 60; + + private final VolmitSender sender; + private final String title; + private final String worldName; + + private final AtomicReference stage = new AtomicReference<>("Preparing"); + private final AtomicReference progress = new AtomicReference<>(0.0D); + private final AtomicBoolean complete = new AtomicBoolean(false); + private final AtomicBoolean failed = new AtomicBoolean(false); + private final AtomicInteger applied = new AtomicInteger(0); + private final AtomicInteger failures = new AtomicInteger(0); + private volatile int total = 0; + private volatile long startMs = 0L; + + public ChunkJobReporter(VolmitSender sender, String title, World world) { + this.sender = sender; + this.title = title; + this.worldName = world.getName(); + } + + public static List orderedTargets(int centerChunkX, int centerChunkZ, int radius) { + List targets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + targets.add(new int[]{centerChunkX + dx, centerChunkZ + dz}); + } + } + + targets.sort(Comparator.comparingInt(t -> { + int ox = t[0] - centerChunkX; + int oz = t[1] - centerChunkZ; + return ox * ox + oz * oz; + })); + return targets; + } + + public void start() { + startMs = System.currentTimeMillis(); + startReporter(); + } + + public void setStage(String value) { + stage.set(value); + } + + public void setTotal(int value) { + total = value; + } + + public void countApplied(boolean ok) { + if (ok) { + applied.incrementAndGet(); + } else { + failures.incrementAndGet(); + } + + int finished = applied.get() + failures.get(); + if (total > 0) { + progress.set(Math.min(0.999D, (double) finished / total)); + } + } + + public void finish(boolean error) { + progress.set(1.0D); + failed.set(error || (applied.get() == 0 && total > 0)); + complete.set(true); + } + + public int applied() { + return applied.get(); + } + + public int failures() { + return failures.get(); + } + + private void startReporter() { + boolean player = sender.isPlayer() && sender.player() != null; + BossBar bossBar = player + ? Bukkit.createBossBar(C.GOLD + title + " " + C.AQUA + "WORKING", BarColor.BLUE, BarStyle.SEGMENTED_20) + : null; + if (bossBar != null) { + bossBar.setProgress(0.0D); + bossBar.addPlayer(sender.player()); + bossBar.setVisible(true); + } + + AtomicInteger taskId = new AtomicInteger(-1); + taskId.set(J.ar(() -> { + double currentProgress = Math.max(0.0D, Math.min(1.0D, progress.get())); + int percent = (int) Math.round(currentProgress * 100.0D); + long elapsed = System.currentTimeMillis() - startMs; + + if (complete.get()) { + J.car(taskId.get()); + finishReporter(bossBar, elapsed); + return; + } + + String label = stage.get() + " " + applied.get() + "/" + (total <= 0 ? "?" : total); + if (bossBar != null) { + bossBar.setProgress(Math.min(1.0D, currentProgress)); + bossBar.setTitle(C.GOLD + title + " " + C.AQUA + stage.get() + C.GRAY + " " + C.YELLOW + percent + "%"); + } + if (sender.isPlayer()) { + sender.sendAction(progressBar(currentProgress) + C.GRAY + " " + C.YELLOW + percent + "%" + + C.GRAY + " | " + C.WHITE + label); + } + }, REPORT_INTERVAL_TICKS)); + } + + private void finishReporter(BossBar bossBar, long elapsed) { + boolean ok = !failed.get(); + String summary = applied.get() + "/" + total + " chunk(s) in " + Form.duration(elapsed, 1) + + (failures.get() > 0 ? " (" + failures.get() + " failed)" : ""); + + if (bossBar != null) { + bossBar.setProgress(1.0D); + bossBar.setColor(ok ? BarColor.GREEN : BarColor.RED); + bossBar.setTitle(C.GOLD + title + " " + (ok ? C.GREEN + "DONE" : C.RED + "FAILED") + + C.GRAY + " " + C.YELLOW + summary); + J.a(() -> { + bossBar.removeAll(); + bossBar.setVisible(false); + }, FINISH_LINGER_TICKS); + } + + if (sender.isPlayer()) { + sender.sendAction(progressBar(1.0D) + C.GRAY + " " + (ok ? C.GREEN + "Done" : C.RED + "Failed") + + C.GRAY + " | " + C.WHITE + summary); + } + sender.sendMessage((ok ? C.GREEN + title + " complete: " : C.RED + title + " finished with errors: ") + summary); + Iris.info(title + " done: world=" + worldName + " " + summary); + } + + private String progressBar(double value) { + int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, value)) * PROGRESS_BAR_WIDTH); + StringBuilder bar = new StringBuilder(C.DARK_GRAY + "["); + for (int i = 0; i < PROGRESS_BAR_WIDTH; i++) { + bar.append(i < filled ? C.GREEN + "|" : C.DARK_GRAY + "|"); + } + bar.append(C.DARK_GRAY).append("]"); + return bar.toString(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java b/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java new file mode 100644 index 000000000..dbe6a39f3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java @@ -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 . + */ + +package art.arcane.iris.core.runtime; + +import art.arcane.iris.Iris; +import art.arcane.iris.engine.data.chunk.TerrainChunk; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Biome; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; + +public final class InPlaceChunkRegenerator { + private static final int BIOME_STEP = 4; + private static final int APPLY_AHEAD = 8; + + private final World world; + private final Engine engine; + private final int centerChunkX; + private final int centerChunkZ; + private final int radius; + private final ChunkJobReporter reporter; + + public InPlaceChunkRegenerator(World world, Engine engine, VolmitSender sender, int centerChunkX, int centerChunkZ, int radius) { + this.world = world; + this.engine = engine; + this.centerChunkX = centerChunkX; + this.centerChunkZ = centerChunkZ; + this.radius = Math.max(0, radius); + this.reporter = new ChunkJobReporter(sender, "Regen", world); + } + + public void start() { + reporter.start(); + Thread thread = new Thread(this::run, "Iris Regenerate"); + thread.setDaemon(true); + thread.start(); + } + + private void run() { + boolean error = false; + try { + reporter.setStage("Resetting mantle"); + resetMantleMargin(); + + List targets = ChunkJobReporter.orderedTargets(centerChunkX, centerChunkZ, radius); + reporter.setTotal(targets.size()); + reporter.setStage("Regenerating"); + regenerate(targets); + } catch (Throwable e) { + Iris.reportError(e); + error = true; + } finally { + reporter.finish(error); + } + } + + private void resetMantleMargin() { + EngineMantle engineMantle = engine.getMantle(); + Mantle mantle = engineMantle.getMantle(); + int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; + for (int dx = -margin; dx <= margin; dx++) { + for (int dz = -margin; dz <= margin; dz++) { + mantle.deleteChunk(centerChunkX + dx, centerChunkZ + dz); + } + } + } + + private void regenerate(List targets) throws InterruptedException { + Semaphore inFlight = new Semaphore(APPLY_AHEAD); + CountDownLatch allApplied = new CountDownLatch(targets.size()); + + for (int[] target : targets) { + int chunkX = target[0]; + int chunkZ = target[1]; + + TerrainChunk buffer = TerrainChunk.create(world); + try { + engine.generate(chunkX << 4, chunkZ << 4, buffer, false); + } catch (Throwable e) { + Iris.reportError(e); + reporter.countApplied(false); + allApplied.countDown(); + continue; + } + + inFlight.acquire(); + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + boolean ok = false; + try { + applyToLiveChunk(chunkX, chunkZ, buffer); + ok = true; + } catch (Throwable e) { + Iris.reportError(e); + } finally { + reporter.countApplied(ok); + inFlight.release(); + allApplied.countDown(); + } + }); + + if (!scheduled) { + Iris.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + "."); + reporter.countApplied(false); + inFlight.release(); + allApplied.countDown(); + } + } + + allApplied.await(); + } + + private void applyToLiveChunk(int chunkX, int chunkZ, TerrainChunk buffer) { + Chunk chunk = world.getChunkAt(chunkX, chunkZ); + for (Entity entity : chunk.getEntities()) { + if (!(entity instanceof Player)) { + entity.remove(); + } + } + + int minHeight = world.getMinHeight(); + int maxHeight = world.getMaxHeight(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minHeight; y < maxHeight; y++) { + BlockData data = buffer.getBlockData(x, y, z); + if (data == null) { + continue; + } + Block live = chunk.getBlock(x, y, z); + if (data.getMaterial() == Material.AIR && live.getType() == Material.AIR) { + continue; + } + live.setBlockData(data, false); + } + } + } + + int baseX = chunkX << 4; + int baseZ = chunkZ << 4; + for (int x = 0; x < 16; x += BIOME_STEP) { + for (int z = 0; z < 16; z += BIOME_STEP) { + for (int y = minHeight; y < maxHeight; y += BIOME_STEP) { + Biome biome = buffer.getBiome(x, y, z); + if (biome != null) { + world.setBiome(baseX + x, y, baseZ + z, biome); + } + } + } + } + + engine.updateChunk(chunk); + world.refreshChunk(chunkX, chunkZ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 28f9e064d..791c5627f 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -945,6 +945,9 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat if (object.isEmpty() || object.equals("null")) { return null; } + if (object.startsWith("procedural/")) { + return null; + } int id = Integer.parseInt(v[1]); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 3b6c33afd..77155305a 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -128,7 +128,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " regionCavePlacers=" + region.getCarvingObjects().size()); } ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen, surfaceHeightLookup); - placeProceduralTrees(writer, rng, x, z, surfaceBiome); + placeProceduralObjects(writer, rng, x, z, surfaceBiome, caveBiome, region); UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); IrisDimension dimension = getDimension(); if (upperCtx != null && dimension.isUpperDimensionObjects()) { @@ -360,34 +360,64 @@ public class MantleObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private void placeProceduralTrees(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome) { - IrisProceduralObjects proceduralObjects = surfaceBiome.getProceduralObjects(); + private void placeProceduralObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region) { + placeProceduralFrom(writer, rng, x, z, surfaceBiome.getProceduralObjects(), surfaceBiome.getName()); + placeProceduralFrom(writer, rng, x, z, region.getProceduralObjects(), region.getName()); + if (caveBiome != null && caveBiome != surfaceBiome) { + placeProceduralFrom(writer, rng, x, z, caveBiome.getProceduralObjects(), caveBiome.getName()); + } + } + + @ChunkCoordinates + private void placeProceduralFrom(MantleWriter writer, RNG rng, int x, int z, IrisProceduralObjects proceduralObjects, String scope) { if (proceduralObjects == null || proceduralObjects.isEmpty()) { return; } int blockX = x << 4; int blockZ = z << 4; - for (IrisProceduralTree tree : proceduralObjects.getTrees()) { - if (!rng.chance(tree.getChance() + rng.d(-0.005, 0.005))) { + for (IrisProceduralPlacement p : proceduralObjects.getAllPlacements()) { + if (!rng.chance(p.getChance() + rng.d(-0.005, 0.005))) { continue; } - IrisObjectPlacement placement = tree.asPlacement(); - IObjectPlacer placer = tree.isPlausible() ? new DecayControlPlacer(writer) : writer; - int density = Math.max(1, tree.getDensity()); + IrisObjectPlacement placement = p.asPlacement(); + boolean carving = placement.getCarvingSupport() == CarvingMode.CARVING_ONLY; + if (carving && placement.getMode() == ObjectPlaceMode.CENTER_HEIGHT) { + placement.setMode(ObjectPlaceMode.FAST_MIN_HEIGHT); + } + IObjectPlacer placer = p.isPlausible() ? new DecayControlPlacer(writer) : writer; + int density = Math.max(1, p.getDensity()); for (int i = 0; i < density; i++) { - IrisObject variant = tree.getVariantObject(getData(), rng); + IrisObject variant = p.getVariantObject(getData(), rng); if (variant == null) { continue; } int xx = rng.i(blockX, blockX + 15); int zz = rng.i(blockZ, blockZ + 15); + int id = rng.i(0, Integer.MAX_VALUE); try { - variant.place(xx, -1, zz, placer, placement, rng, getData()); + if (carving) { + int caveFloorY = findNearestCaveFloor(writer, xx, zz); + if (caveFloorY > 0) { + variant.place(xx, caveFloorY, zz, placer, placement, rng, (b, data) -> { + String marker = placementMarker(variant, id, "procedural"); + if (marker != null) { + writer.setData(b.getX(), b.getY(), b.getZ(), marker); + } + }, null, getData()); + } + } else { + variant.place(xx, -1, zz, placer, placement, rng, (b, data) -> { + String marker = placementMarker(variant, id, "procedural"); + if (marker != null) { + writer.setData(b.getX(), b.getY(), b.getZ(), marker); + } + }, null, getData()); + } } catch (Throwable e) { Iris.reportError(e); - Iris.error("Failed to place procedural tree '" + tree.getName() + "' in biome " + surfaceBiome.getName()); + Iris.error("Failed to place procedural object '" + p.getName() + "' in " + scope); e.printStackTrace(); } } @@ -1339,6 +1369,7 @@ public class MantleObjectComponent extends IrisMantleComponent { KSet objects = new KSet<>(); KMap> scalars = new KMap<>(); + KList vacuumPlacements = new KList<>(); for (IrisRegion region : dimension.getAllRegions(this::getData)) { for (IrisObjectPlacement j : region.getObjects()) { if (j.getScale().canScaleBeyond()) { @@ -1346,6 +1377,9 @@ public class MantleObjectComponent extends IrisMantleComponent { } else { objects.addAll(j.getPlace()); } + if (IrisObjectVacuum.isVacuumMode(j.getMode())) { + vacuumPlacements.add(j); + } } } for (IrisBiome biome : dimension.getAllBiomes(this::getData)) { @@ -1355,6 +1389,9 @@ public class MantleObjectComponent extends IrisMantleComponent { } else { objects.addAll(j.getPlace()); } + if (IrisObjectVacuum.isVacuumMode(j.getMode())) { + vacuumPlacements.add(j); + } } } @@ -1370,6 +1407,10 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + for (IrisObjectPlacement j : vacuumPlacements) { + updateVacuumRadiusBounds(sizeCache, xg, zg, j); + } + return Math.max(xg.get(), zg.get()); } @@ -1401,6 +1442,35 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + private void updateVacuumRadiusBounds( + KMap sizeCache, + AtomicInteger xg, + AtomicInteger zg, + IrisObjectPlacement placement + ) { + int pad = 2 * IrisObjectVacuum.resolveRadius(placement.getMode(), placement.getVacuumSettings()); + if (pad <= 0) { + return; + } + + double scale = placement.getScale() != null ? Math.max(1D, placement.getScale().getMaxScale()) : 1D; + for (String objectKey : placement.getPlace()) { + try { + BlockVector bv = loadObjectSize(sizeCache, objectKey); + if (bv == null) { + continue; + } + + int reachX = (int) Math.ceil(Math.abs(bv.getBlockX()) * scale) + pad; + int reachZ = (int) Math.ceil(Math.abs(bv.getBlockZ()) * scale) + pad; + xg.getAndSet(Math.max(reachX, xg.get())); + zg.getAndSet(Math.max(reachZ, zg.get())); + } catch (Throwable e) { + Iris.reportError(e); + } + } + } + private BlockVector loadObjectSize(KMap sizeCache, String objectKey) { return sizeCache.computeIfAbsent(objectKey, k -> { try { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCoral.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCoral.java new file mode 100644 index 000000000..90ded30fb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCoral.java @@ -0,0 +1,235 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.engine.object.coral.CoralGenerator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("coral") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A single procedurally generated coral reef structure. Iris bakes a pool of deterministic variants from these settings and scatters them across the seafloor at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file. Mirrors the procedural tree system but tuned for underwater reefs: structural blocks are waterlogged so the coral stays alive, and placement defaults to anchoring on the terrain beneath the water surface.") +@Data +public class IrisCoral implements IrisProceduralPlacement { + private final transient AtomicCache> variantCache = new AtomicCache<>(); + + @Desc("A human readable name used in logs and as the variant load key.") + private String name = "coral"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per chunk for this coral to attempt placement. Use density for multiple per chunk.") + private double chance = 0.4; + + @MinNumber(1) + @Desc("If the chance check passes, attempt this many placements in the chunk.") + private int density = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("How many distinct variants to pre-bake for this coral. Higher means more variety at a small memory cost.") + private int variants = 6; + + @Desc("The base seed for deterministic generation. The same seed and settings always bake the same variants.") + private long seed = 1337; + + @Desc("The placement mode used to anchor the coral to the seafloor.") + private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT; + + @Desc("Rotate this coral's placement.") + private IrisObjectRotation rotation = new IrisObjectRotation(); + + @Desc("Limit the max or min height of placement.") + private IrisObjectLimit clamp = new IrisObjectLimit(); + + @Desc("Whether this coral may place on the terrain surface, under carvings, or both.") + private CarvingMode carvingSupport = CarvingMode.SURFACE_ONLY; + + @Desc("If true (default for coral), the coral anchors on the terrain height beneath the water, so it grows up from the seafloor rather than floating at the water surface.") + private boolean underwater = true; + + @Desc("Translate (offset) this placement along each axis; for example set a negative y to sink it into the seafloor.") + private IrisObjectTranslate translate = new IrisObjectTranslate(); + + @Desc("Settings for the stilt place modes (STILT, MIN_STILT, FAST_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT).") + private IrisStiltSettings stiltSettings; + + @Desc("Settings for the vacuum place modes (VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC).") + private IrisVacuumSettings vacuumSettings; + + @Desc("If true (default), every resolved coral block that is waterloggable is forced waterlogged so the coral does not die in water (coral fans, sea pickles and kelp also waterlog). Set false to place dead/dry coral.") + private boolean waterlogged = true; + + @Desc("The overall silhouette this coral grows into (branching, fan, brain, pillar, tendril). Each form runs a different generation routine.") + private IrisCoralForm form = IrisCoralForm.BRANCHING; + + @Required + @Desc("The structural coral block (the living wood of the reef), e.g. minecraft:tube_coral_block. Ignored when blockPalette is set.") + private String block = "minecraft:tube_coral_block"; + + @Desc("A noise-driven palette for the structural coral block. When set this overrides the single block, letting the reef mix tube/brain/bubble/fire/horn coral_block tones across its body.") + private IrisMaterialPalette blockPalette = null; + + @Desc("Optional tip block placed at branch tips and at the very top of the structure, e.g. coral fans or minecraft:sea_pickle. Ignored when tipPalette is set. Null disables tips.") + private String tipBlock = null; + + @Desc("A noise-driven palette for the tip block. When set this overrides the single tipBlock, letting tips mix fan / pickle decorations.") + private IrisMaterialPalette tipPalette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per eligible tip position to place a tip block (coral fan / sea pickle).") + private double tipChance = 0.6; + + @MinNumber(1) + @Desc("Minimum overall height of the coral in blocks.") + private int heightMin = 4; + + @MinNumber(1) + @Desc("Maximum overall height of the coral in blocks.") + private int heightMax = 8; + + @MinNumber(0) + @Desc("Horizontal spread of the structure in blocks. For BRANCHING this is how far arms reach out, for BRAIN/PILLAR/FAN it widens the base footprint.") + private double spread = 3; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Lateral noise wobble (0-1) applied to branches and tendrils so they are not ruler-straight. 0 is perfectly straight, 1 is heavily wandering.") + private double sway = 0.5; + + @MinNumber(1) + @MaxNumber(12) + @Desc("BRANCHING only: how many arms sprout from the central stalk.") + private int branchCount = 4; + + @MinNumber(1) + @Desc("BRANCHING only: the length of each arm in blocks before reaching its tip.") + private double branchLength = 3; + + @MinNumber(0) + @MaxNumber(90) + @Desc("BRANCHING only: the upward elevation in degrees of each arm from horizontal. 90 is straight up, 0 is flat out.") + private double branchElevation = 55; + + @Desc("BRANCHING only: how the arms are distributed around the stalk by azimuth.") + private IrisCoralBranchAzimuth branchAzimuth = IrisCoralBranchAzimuth.GOLDEN_ANGLE; + + @Desc("BRANCHING only: if true, each arm splits once into a small fan of sub-arms for a bushier reef.") + private boolean subBranches = true; + + @MinNumber(1) + @MaxNumber(5) + @Desc("BRANCHING only: how many sub-arms each arm splits into when subBranches is true.") + private int subBranchCount = 2; + + @MinNumber(0) + @MaxNumber(1) + @Desc("BRANCHING only: the length of each sub-arm as a fraction of its parent arm.") + private double subBranchScale = 0.5; + + @MinNumber(0) + @MaxNumber(4) + @Desc("BRANCHING and PILLAR only: the radius of the small coral tip cluster grown at each tip.") + private int tipClusterRadius = 1; + + @MinNumber(1) + @MaxNumber(8) + @Desc("BRAIN only: the horizontal radius of the brain-coral blob in blocks.") + private int brainRadius = 3; + + @MinNumber(0) + @MaxNumber(1) + @Desc("BRAIN only: how strongly 3D value noise perturbs the ellipsoid surface, for a wrinkled organic blob. 0 is a smooth dome.") + private double brainRoughness = 0.35; + + @MinNumber(1) + @MaxNumber(12) + @Desc("PILLAR only: the radius of the stout vertical column in blocks.") + private int pillarRadius = 1; + + @MinNumber(1) + @MaxNumber(8) + @Desc("FAN only: the half-width of the upright fan plane in blocks (the disc widens toward its mid-height).") + private int fanWidth = 3; + + @MinNumber(1) + @MaxNumber(12) + @Desc("TENDRIL only: how many thin wavy stalks rise from the base.") + private int tendrilCount = 4; + + public KList getVariantObjects(IrisData data) { + return variantCache.aquire(() -> { + KList baked = new KList<>(); + int count = Math.max(1, variants); + + for (int i = 0; i < count; i++) { + IrisObject object = CoralGenerator.generate(this, i, new RNG(seed + (i * 7919L)), data); + if (object == null || object.getBlocks().isEmpty()) { + continue; + } + object.setLoadKey("procedural/" + name + "#" + i); + object.setLoader(data); + baked.add(object); + } + + return baked; + }); + } + + public IrisObject getVariantObject(IrisData data, RNG rng) { + KList baked = getVariantObjects(data); + if (baked.isEmpty()) { + return null; + } + return baked.get(rng.i(baked.size())); + } + + public IrisObjectPlacement asPlacement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(mode); + placement.setRotation(rotation); + placement.setClamp(clamp); + placement.setCarvingSupport(carvingSupport); + placement.setUnderwater(underwater); + placement.setTranslate(translate); + placement.setStiltSettings(stiltSettings); + placement.setVacuumSettings(vacuumSettings); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } + + public boolean isPlausible() { + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCoralBranchAzimuth.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCoralBranchAzimuth.java new file mode 100644 index 000000000..1e711dd10 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCoralBranchAzimuth.java @@ -0,0 +1,33 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How the arms of a BRANCHING coral are distributed around the central stalk by compass azimuth.") +public enum IrisCoralBranchAzimuth { + @Desc("Distribute arms by the golden angle (about 137.5 degrees per arm) for a natural phyllotactic spiral where no two arms overlap.") + GOLDEN_ANGLE, + + @Desc("Distribute arms in evenly spaced spokes, dividing 360 degrees equally between them like a radial whorl.") + EVEN, + + @Desc("Place each arm at a fully random azimuth using the deterministic per-variant RNG, for an irregular wild reef.") + RANDOM +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCoralForm.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCoralForm.java new file mode 100644 index 000000000..8f3b321d2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCoralForm.java @@ -0,0 +1,39 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The overall silhouette a procedural coral grows into. Each form drives a completely different generation routine in the coral generator, from tree-like branching arms to rounded brain blobs and thin wavy tendrils.") +public enum IrisCoralForm { + @Desc("A tree-like coral built from a stout central stalk that sprouts several upward-leaning arms (distributed by golden angle or randomly), each optionally splitting once into sub-arms, with small coral tip clusters (fans / sea pickles) at the ends. The most structurally complex form and the closest analogue to a procedural tree.") + BRANCHING, + + @Desc("A thin, single-block-thick vertical fan plane that rises and widens with height like a sheet of fire or horn coral. Reads as a flat decorative blade rather than a volume.") + FAN, + + @Desc("A rounded, noise-perturbed ellipsoid blob of brain coral that bulges out of the seafloor like a boulder, with an irregular wrinkled surface driven by 3D value noise.") + BRAIN, + + @Desc("A stout, thick vertical column of coral block topped with a dense tip cluster. Wider and blunter than a tendril, reading as a solid coral pillar or stack.") + PILLAR, + + @Desc("Several very thin (one block wide) wavy stalks that rise upward and wander laterally using value noise, like a clump of swaying coral whips or kelp-thin growths.") + TENDRIL +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCrystal.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCrystal.java new file mode 100644 index 000000000..bd8810c0c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCrystal.java @@ -0,0 +1,214 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.engine.object.crystal.CrystalGenerator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("crystal") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A single procedurally generated crystal cluster: a budding base blob with faceted, tapered shards radiating from it (geode / amethyst-style). Iris bakes a pool of deterministic variants from these settings and scatters them at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file. Built for caves.") +@Data +public class IrisCrystal implements IrisProceduralPlacement { + private final transient AtomicCache> variantCache = new AtomicCache<>(); + + @Desc("A human readable name used in logs and as the variant load key.") + private String name = "crystal"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per chunk for this crystal cluster to attempt placement. Use density for multiple per chunk.") + private double chance = 0.2; + + @MinNumber(1) + @Desc("If the chance check passes, attempt this many placements in the chunk.") + private int density = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("How many distinct variants to pre-bake for this crystal cluster. Higher means more variety at a small memory cost.") + private int variants = 6; + + @Desc("The base seed for deterministic generation. The same seed and settings always bake the same variants.") + private long seed = 1337; + + @Desc("The placement mode used to anchor the crystal cluster to the terrain.") + private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT; + + @Desc("Rotate this crystal cluster's placement.") + private IrisObjectRotation rotation = new IrisObjectRotation(); + + @Desc("Limit the max or min height of placement.") + private IrisObjectLimit clamp = new IrisObjectLimit(); + + @Desc("Whether this crystal cluster may place on the terrain surface, under carvings, or both. Crystals are meant for caves, so this defaults to CARVING_ONLY.") + private CarvingMode carvingSupport = CarvingMode.CARVING_ONLY; + + @Desc("If true, the crystal cluster anchors on the terrain height ignoring the water surface.") + private boolean underwater = false; + + @Desc("Translate (offset) this placement along each axis; for example set a negative y to sink it into the ground.") + private IrisObjectTranslate translate = new IrisObjectTranslate(); + + @Desc("Settings for the stilt place modes (STILT, MIN_STILT, FAST_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT).") + private IrisStiltSettings stiltSettings; + + @Desc("Settings for the vacuum place modes (VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC).") + private IrisVacuumSettings vacuumSettings; + + @Desc("The surface this crystal grows from (FLOOR shards up, CEILING shards down, WALL shards outward). This orients the baked geometry only.") + private IrisCrystalSurface growthSurface = IrisCrystalSurface.FLOOR; + + @Required + @Desc("The primary crystal shard block, e.g. minecraft:amethyst_block. Ignored when blockPalette is set.") + private String block = "minecraft:amethyst_block"; + + @Desc("A noise-driven palette for the crystal shards (a prismatic mix, e.g. amethyst_block, calcite, tinted_glass). When set this overrides the single block, letting the shards mix blocks by noise. Palette wins.") + private IrisMaterialPalette blockPalette = null; + + @Desc("Optional block placed at the very tip of each shard for a different colored or sparkling point (e.g. minecraft:amethyst_cluster or glowstone). Ignored when tipPalette is set. If unset and glow is true, a light-emitting block is sprinkled among the tips instead.") + private String tipBlock = null; + + @Desc("A noise-driven palette for the shard tips. When set this overrides the single tipBlock. Palette wins.") + private IrisMaterialPalette tipPalette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per shard that its very tip is replaced by the tip block (or glow block).") + private double tipChance = 0.6; + + @Desc("If true and no tipBlock/tipPalette is set, sprinkle a light-emitting block (the glowBlock) among the shard tips for a sparkling geode.") + private boolean glow = false; + + @Desc("The light-emitting block sprinkled among the tips when glow is true and no tip block is configured.") + private String glowBlock = "minecraft:glowstone"; + + @Desc("Optional block for the budding base blob the shards grow from, e.g. minecraft:budding_amethyst or calcite. Ignored when basePalette is set. If unset, the base is built from the primary shard block.") + private String baseBlock = "minecraft:budding_amethyst"; + + @Desc("A noise-driven palette for the budding base blob. When set this overrides the single baseBlock. Palette wins.") + private IrisMaterialPalette basePalette = null; + + @MinNumber(0) + @Desc("Radius in blocks of the budding base blob the shards radiate from. 0 places no base blob (shards spring from a single point).") + private double baseRadius = 1.6; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How strongly value noise perturbs the surface of the base blob, for a lumpy organic budding base rather than a clean sphere.") + private double baseNoise = 0.35; + + @MinNumber(1) + @Desc("Minimum number of shards radiating from the base.") + private int shardCountMin = 5; + + @MinNumber(1) + @Desc("Maximum number of shards radiating from the base.") + private int shardCountMax = 11; + + @MinNumber(1) + @Desc("Minimum length in blocks of a shard, measured from the base out to its pointed tip.") + private int shardLengthMin = 3; + + @MinNumber(1) + @Desc("Maximum length in blocks of a shard, measured from the base out to its pointed tip.") + private int shardLengthMax = 8; + + @MinNumber(0.5) + @Desc("Starting radius in blocks of a shard at its thick base end. The shard tapers from this down to a single block at the tip.") + private double shardBaseRadius = 1.4; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How aggressively each shard narrows from base to tip. 0 keeps a near-constant column; 1 tapers fully to a sharp point at the very tip. Each shard always ends in a single pointed block.") + private double shardTaper = 0.85; + + @MinNumber(0) + @MaxNumber(90) + @Desc("The half-angle in degrees of the cone the shards fan within around the surface normal. 0 makes all shards parallel to the normal; larger values splay them outward into a starburst.") + private double spreadAngle = 45; + + @Desc("How the shard azimuths are distributed around the normal (RANDOM for a chaotic clump, GOLDEN_ANGLE for an evenly spaced rosette).") + private IrisCrystalDistribution distribution = IrisCrystalDistribution.GOLDEN_ANGLE; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Per-shard angular randomness (0-1) added on top of the chosen distribution so the cluster never looks mechanically regular. 0 is perfectly distributed; 1 is heavily jittered.") + private double jitter = 0.25; + + public KList getVariantObjects(IrisData data) { + return variantCache.aquire(() -> { + KList baked = new KList<>(); + int count = Math.max(1, variants); + + for (int i = 0; i < count; i++) { + IrisObject object = CrystalGenerator.generate(this, i, new RNG(seed + (i * 7919L)), data); + if (object == null || object.getBlocks().isEmpty()) { + continue; + } + object.setLoadKey("procedural/" + name + "#" + i); + object.setLoader(data); + baked.add(object); + } + + return baked; + }); + } + + public IrisObject getVariantObject(IrisData data, RNG rng) { + KList baked = getVariantObjects(data); + if (baked.isEmpty()) { + return null; + } + return baked.get(rng.i(baked.size())); + } + + public IrisObjectPlacement asPlacement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(mode); + placement.setRotation(rotation); + placement.setClamp(clamp); + placement.setCarvingSupport(carvingSupport); + placement.setUnderwater(underwater); + placement.setTranslate(translate); + placement.setStiltSettings(stiltSettings); + placement.setVacuumSettings(vacuumSettings); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } + + public boolean isPlausible() { + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCrystalDistribution.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCrystalDistribution.java new file mode 100644 index 000000000..adc4a1f7f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCrystalDistribution.java @@ -0,0 +1,30 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How the shards of a crystal cluster are angularly distributed around the surface normal.") +public enum IrisCrystalDistribution { + @Desc("Each shard gets a uniformly random azimuth around the normal. Produces a chaotic, naturally clumped cluster.") + RANDOM, + + @Desc("Each shard's azimuth advances by the golden angle (~137.5 degrees) from the previous one. Produces an evenly spaced sunflower-like fan with no two shards overlapping, ideal for a geode rosette.") + GOLDEN_ANGLE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCrystalSurface.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCrystalSurface.java new file mode 100644 index 000000000..49cb1b7fb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCrystalSurface.java @@ -0,0 +1,33 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The surface a crystal cluster grows from. This only orients the baked geometry (which way the shards point); placement of the object into a cave is handled by the placement system, not here.") +public enum IrisCrystalSurface { + @Desc("Grows from a cave floor. The budding base sits at the bottom and the shards point upward, fanning within the spread cone around the up axis. Built with positive py so the cluster grows up from the base layer.") + FLOOR, + + @Desc("Grows from a cave ceiling. The budding base sits at the top and the shards point downward like stalactites, fanning within the spread cone around the down axis. Built with negative py so the cluster grows down from the ceiling.") + CEILING, + + @Desc("Grows from a cave wall. The budding base hugs the wall and the shards point outward and slightly upward, fanning within the spread cone around a tilted normal so the cluster reads as a wall sconce of shards.") + WALL +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFormation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFormation.java new file mode 100644 index 000000000..b2366ef11 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFormation.java @@ -0,0 +1,239 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.engine.object.formation.FormationGenerator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("formation") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A single procedurally generated rock formation (a natural landmark such as a spire, hoodoo, arch, sea stack, boulder or basalt column cluster). Iris bakes a pool of deterministic variants from these settings and scatters them at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file.") +@Data +public class IrisFormation implements IrisProceduralPlacement { + private final transient AtomicCache> variantCache = new AtomicCache<>(); + + @Desc("A human readable name used in logs and as the variant load key.") + private String name = "formation"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per chunk for this formation to attempt placement. Use density for multiple per chunk.") + private double chance = 0.02; + + @MinNumber(1) + @Desc("If the chance check passes, attempt this many placements in the chunk.") + private int density = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("How many distinct variants to pre-bake for this formation. Higher means more variety at a small memory cost.") + private int variants = 6; + + @Desc("The base seed for deterministic generation. The same seed and settings always bake the same variants.") + private long seed = 1337; + + @Desc("The placement mode used to anchor the formation to the terrain.") + private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT; + + @Desc("Rotate this formation's placement.") + private IrisObjectRotation rotation = new IrisObjectRotation(); + + @Desc("Limit the max or min height of placement.") + private IrisObjectLimit clamp = new IrisObjectLimit(); + + @Desc("Whether this formation may place on the terrain surface, under carvings, or both.") + private CarvingMode carvingSupport = CarvingMode.SURFACE_ONLY; + + @Desc("If true, the formation anchors on the terrain height ignoring the water surface.") + private boolean underwater = false; + + @Desc("Translate (offset) this placement along each axis; for example set a negative y to sink it into the ground.") + private IrisObjectTranslate translate = new IrisObjectTranslate(); + + @Desc("Settings for the stilt place modes (STILT, MIN_STILT, FAST_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT).") + private IrisStiltSettings stiltSettings; + + @Desc("Settings for the vacuum place modes (VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC).") + private IrisVacuumSettings vacuumSettings; + + @Desc("The overall silhouette of the formation. Drives which sculpting routine the generator uses.") + private IrisFormationForm form = IrisFormationForm.SPIRE; + + @Required + @Desc("The main rock block, e.g. minecraft:stone. Ignored when blockPalette is set.") + private String block = "minecraft:stone"; + + @Desc("A noise-driven palette for the main rock body. When set this overrides the single block, letting the body mix blocks by noise.") + private IrisMaterialPalette blockPalette = null; + + @Desc("Optional caprock block placed on the top crown of the formation (and the wide overhanging cap for HOODOO). Ignored when capPalette is set. When null and capPalette is unset, the formation uses its main rock everywhere.") + private String capBlock = null; + + @Desc("A noise-driven palette for the caprock. When set this overrides the single capBlock.") + private IrisMaterialPalette capPalette = null; + + @Desc("Optional palette of strata (horizontal colored bands) blended into the rock body by y level. When set, every strataThickness blocks of height switches to the next strata block, producing the signature banded look. Falls back to the main block where unset.") + private IrisMaterialPalette strataPalette = null; + + @MinNumber(1) + @MaxNumber(32) + @Desc("The thickness in blocks of each horizontal strata band when strataPalette is set.") + private int strataThickness = 3; + + @MinNumber(1) + @Desc("Minimum total height of the formation in blocks.") + private int heightMin = 14; + + @MinNumber(1) + @Desc("Maximum total height of the formation in blocks.") + private int heightMax = 26; + + @MinNumber(1) + @Desc("Minimum base radius (half-width) of the formation in blocks.") + private int baseWidthMin = 3; + + @MinNumber(1) + @Desc("Maximum base radius (half-width) of the formation in blocks.") + private int baseWidthMax = 6; + + @MinNumber(0) + @Desc("The target radius (half-width) at the very top of the formation in blocks, before the profile is applied. 0 means taper to a point.") + private int topWidth = 0; + + @Desc("The function used to shape the formation radius over its height.") + private IrisFormationProfile profile = IrisFormationProfile.TAPER; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The normalized height (0 base, 1 top) of the pinched waist for the PARABOLIC profile (used by HOODOO).") + private double profileWaist = 0.55; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The minimum radius fraction at the waist for the PARABOLIC profile. Lower values pinch the waist tighter.") + private double profileWaistFloor = 0.35; + + @MinNumber(0) + @Desc("How far the formation leans, in degrees from vertical. 0 is perfectly upright. The whole body is sheared by this amount over its height.") + private double lean = 0; + + @Desc("Compass direction in degrees the formation leans toward when lean is non-zero.") + private double leanAzimuth = 0; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Surface roughness from 0 (a clean smooth solid) to 1 (heavily eroded and pitted). Driven by 3D value noise that perturbs the radius so the formation never reads as a clean cylinder.") + private double roughness = 0.3; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Per-block surface jitter from 0 (none) to 1 (maximum). Randomly carves and adds isolated edge blocks to break up the silhouette.") + private double jitter = 0.15; + + @MinNumber(0) + @Desc("Extra wide cap radius in blocks added on top for the HOODOO form, producing the overhanging mushroom caprock. 0 disables the overhang.") + private int hoodooCapRadius = 3; + + @MinNumber(1) + @MaxNumber(6) + @Desc("The height in blocks of the HOODOO overhanging caprock slab.") + private int hoodooCapHeight = 3; + + @MinNumber(0) + @Desc("How wide the ARCH opening is in blocks (the gap between the two legs). The arch span is rasterized over this width.") + private int archSpan = 10; + + @MinNumber(1) + @Desc("The thickness in blocks of the ARCH legs and spanning curve.") + private int archThickness = 3; + + @MinNumber(2) + @MaxNumber(12) + @Desc("How many separate columns make up a BASALT_COLUMN cluster. Each column gets a randomized height around the formation height range.") + private int basaltColumns = 5; + + @MinNumber(1) + @Desc("The radius (half-width) in blocks of each individual column in a BASALT_COLUMN cluster.") + private int basaltColumnRadius = 1; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How much the heights of individual BASALT_COLUMN columns vary from each other, from 0 (all equal) to 1 (highly varied).") + private double basaltHeightVariance = 0.45; + + public KList getVariantObjects(IrisData data) { + return variantCache.aquire(() -> { + KList baked = new KList<>(); + int count = Math.max(1, variants); + + for (int i = 0; i < count; i++) { + IrisObject object = FormationGenerator.generate(this, i, new RNG(seed + (i * 7919L)), data); + if (object == null || object.getBlocks().isEmpty()) { + continue; + } + object.setLoadKey("procedural/" + name + "#" + i); + object.setLoader(data); + baked.add(object); + } + + return baked; + }); + } + + public IrisObject getVariantObject(IrisData data, RNG rng) { + KList baked = getVariantObjects(data); + if (baked.isEmpty()) { + return null; + } + return baked.get(rng.i(baked.size())); + } + + public IrisObjectPlacement asPlacement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(mode); + placement.setRotation(rotation); + placement.setClamp(clamp); + placement.setCarvingSupport(carvingSupport); + placement.setUnderwater(underwater); + placement.setTranslate(translate); + placement.setStiltSettings(stiltSettings); + placement.setVacuumSettings(vacuumSettings); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } + + public boolean isPlausible() { + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFormationForm.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFormationForm.java new file mode 100644 index 000000000..24f83084a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFormationForm.java @@ -0,0 +1,37 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The overall silhouette of a procedural rock formation. Each form drives a distinct sculpting routine in the formation generator.") +public enum IrisFormationForm { + @Desc("A tall, slender needle of rock that tapers smoothly to a point at the top (a sharp pinnacle or stone spire).") + SPIRE, + @Desc("A column with a pinched, eroded waist and a wide overhanging caprock balanced on top (the classic desert hoodoo / mushroom rock).") + HOODOO, + @Desc("Two stout legs joined by a spanning curved bridge of rock overhead (a natural stone arch).") + ARCH, + @Desc("A chunky, blocky stack that tapers gently as it rises, like an isolated pillar of rock standing in water (a sea stack).") + SEA_STACK, + @Desc("A rounded, lumpy boulder formed from a noise-perturbed ellipsoid that sits low on the terrain.") + BOULDER, + @Desc("A tightly packed cluster of several vertical, near-hexagonal columns of varying height (a basalt column formation / giant's causeway).") + BASALT_COLUMN +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFormationProfile.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFormationProfile.java new file mode 100644 index 000000000..f252c298c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFormationProfile.java @@ -0,0 +1,35 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("A width-shaping function evaluated over normalized height (0 at the base, 1 at the top). Controls how the formation radius scales between baseWidth at the bottom and topWidth at the top.") +public enum IrisFormationProfile { + @Desc("Same radius at every height (a straight cylinder of constant baseWidth).") + CONSTANT, + @Desc("Radius ramps straight from baseWidth at the bottom to topWidth at the top.") + LINEAR, + @Desc("Radius shrinks smoothly toward a point as height increases (an ease that drives the SPIRE needle and gentle SEA_STACK taper).") + TAPER, + @Desc("Radius pinches to a narrow waist near profileWaist then widens again toward the top (the eroded waist used by HOODOO columns).") + PARABOLIC, + @Desc("Radius bulges out to its maximum in the middle of the height range and narrows at both ends (a barrel / bulged column).") + BULGE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFungus.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFungus.java new file mode 100644 index 000000000..76bf6109f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFungus.java @@ -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 . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.engine.object.fungi.FungusGenerator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("fungus") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A single procedurally generated fungus (giant mushroom or shelf fungus). Iris bakes a pool of deterministic variants from these settings and scatters them at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file. The fungus is built as a stem column with a cap dome grown on top, mirroring the trunk-and-canopy model used by procedural trees.") +@Data +public class IrisFungus implements IrisProceduralPlacement { + private final transient AtomicCache> variantCache = new AtomicCache<>(); + + @Desc("A human readable name used in logs and as the variant load key.") + private String name = "fungus"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per chunk for this fungus to attempt placement. Use density for multiple per chunk.") + private double chance = 0.4; + + @MinNumber(1) + @Desc("If the chance check passes, attempt this many placements in the chunk.") + private int density = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("How many distinct variants to pre-bake for this fungus. Higher means more variety at a small memory cost.") + private int variants = 6; + + @Desc("The base seed for deterministic generation. The same seed and settings always bake the same variants.") + private long seed = 1337; + + @Desc("The placement mode used to anchor the fungus to the terrain.") + private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT; + + @Desc("Rotate this fungus's placement.") + private IrisObjectRotation rotation = new IrisObjectRotation(); + + @Desc("Limit the max or min height of placement.") + private IrisObjectLimit clamp = new IrisObjectLimit(); + + @Desc("Whether this fungus may place on the terrain surface, under carvings, or both.") + private CarvingMode carvingSupport = CarvingMode.SURFACE_ONLY; + + @Desc("If true, the fungus anchors on the terrain height ignoring the water surface.") + private boolean underwater = false; + + @Desc("Translate (offset) this placement along each axis; for example set a negative y to sink it into the ground.") + private IrisObjectTranslate translate = new IrisObjectTranslate(); + + @Desc("Settings for the stilt place modes (STILT, MIN_STILT, FAST_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT).") + private IrisStiltSettings stiltSettings; + + @Desc("Settings for the vacuum place modes (VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC).") + private IrisVacuumSettings vacuumSettings; + + @Required + @Desc("The stem block, e.g. minecraft:mushroom_stem. Ignored when stemPalette is set.") + private String stem = "minecraft:mushroom_stem"; + + @Desc("A noise-driven palette for the stem. When set this overrides the single stem block, letting the stem mix blocks by noise.") + private IrisMaterialPalette stemPalette = null; + + @Required + @Desc("The cap block, e.g. minecraft:red_mushroom_block. Ignored when capPalette is set.") + private String cap = "minecraft:red_mushroom_block"; + + @Desc("A noise-driven palette for the cap. When set this overrides the single cap block, letting the cap mix blocks by noise.") + private IrisMaterialPalette capPalette = null; + + @MinNumber(1) + @Desc("Minimum total stem height in blocks. Variants spread between this and stemHeightMax.") + private int stemHeightMin = 5; + + @MinNumber(1) + @Desc("Maximum total stem height in blocks. Variants spread between stemHeightMin and this.") + private int stemHeightMax = 9; + + @MinNumber(1) + @MaxNumber(3) + @Desc("Base stem width in blocks. 1 is a single column, 2 is a 2x2 stem, 3 is a chunky 3x3 trunk.") + private int stemWidth = 1; + + @Desc("How far the stem leans away from vertical, in degrees. 0 is perfectly upright; small values give an organic tilt.") + private double stemCurve = 0; + + @Desc("Compass direction in degrees the stem leans toward when stemCurve is non-zero.") + private double stemLeanAzimuth = 0; + + @MinNumber(0) + @Desc("Amplitude in blocks of a gentle sideways wave applied up the stem so it is not a perfectly straight ruler.") + private double stemWaveAmplitude = 0.4; + + @MinNumber(0.0001) + @Desc("The number of full sine wobbles applied over the stem height for the stem wave.") + private double stemWavePeriods = 1; + + @Desc("The silhouette of the cap (dome, flat umbrella, funnel, cone, or wide shallow slab).") + private IrisFungusCapShape capShape = IrisFungusCapShape.DOME; + + @MinNumber(1) + @Desc("Minimum cap radius in blocks (measured from the cap center to its rim).") + private int capRadiusMin = 3; + + @MinNumber(1) + @Desc("Maximum cap radius in blocks (measured from the cap center to its rim).") + private int capRadiusMax = 5; + + @MinNumber(1) + @MaxNumber(3) + @Desc("How many blocks thick the cap shell is. 1 is a thin skin, 3 is a fleshy slab.") + private int capThickness = 1; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Vertical flatten factor for the cap. 0 leaves the cap at full height, 1 squashes it flat into a disc.") + private double capSquish = 0.4; + + @MinNumber(0) + @Desc("How far the cap rim droops downward, in degrees, curling the edge of the cap toward the ground.") + private double capDroop = 20; + + @MinNumber(0) + @Desc("How far the cap radius extends past the stem before the rim begins, in blocks. Larger values make the cap overhang the stem more.") + private double capOverhang = 2; + + @Desc("Optional block forming the gill layer on the underside of the cap (gills or a glow layer), e.g. minecraft:brown_mushroom_block or minecraft:shroomlight. Ignored when gillPalette is set or left null.") + private String gillBlock = null; + + @Desc("A noise-driven palette for the underside gill layer. When set this overrides the single gillBlock.") + private IrisMaterialPalette gillPalette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The fraction of underside cap blocks replaced by the gill block when a gill block or palette is set.") + private double gillChance = 0.85; + + @Desc("Optional block speckled across the top of the cap by noise (white toadstool dots, warts, glowing spots), e.g. minecraft:bone_block or minecraft:white_concrete. Ignored when spotPalette is set or left null.") + private String spotBlock = null; + + @Desc("A noise-driven palette for the cap top spots. When set this overrides the single spotBlock.") + private IrisMaterialPalette spotPalette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The fraction of top cap blocks replaced by the spot block, selected by value noise so spots cluster naturally.") + private double spotChance = 0.18; + + @Desc("If true, generate a bracket / shelf fungus instead of an upright mushroom: a small flat fan that grows sideways off a very short or absent stem, like a polypore clinging to a trunk.") + private boolean shelf = false; + + @MinNumber(1) + @Desc("The radius in blocks of the sideways fan when shelf is true.") + private int shelfRadius = 3; + + public KList getVariantObjects(IrisData data) { + return variantCache.aquire(() -> { + KList baked = new KList<>(); + int count = Math.max(1, variants); + int lo = Math.min(stemHeightMin, stemHeightMax); + int hi = Math.max(stemHeightMin, stemHeightMax); + RNG heightRng = new RNG(seed); + + for (int i = 0; i < count; i++) { + int height; + if (count == 1 || lo == hi) { + height = heightRng.i(lo, hi + 1); + } else { + double step = (hi - lo) / (double) (count - 1); + double base = lo + step * i; + double jitter = heightRng.d(-step * 0.3, step * 0.3); + height = (int) Math.round(Math.max(lo, Math.min(hi, base + jitter))); + } + + IrisObject object = FungusGenerator.generate(this, Math.max(1, height), new RNG(seed + (i * 7919L)), data); + if (object == null || object.getBlocks().isEmpty()) { + continue; + } + object.setLoadKey("procedural/" + name + "#" + i); + object.setLoader(data); + baked.add(object); + } + + return baked; + }); + } + + public IrisObject getVariantObject(IrisData data, RNG rng) { + KList baked = getVariantObjects(data); + if (baked.isEmpty()) { + return null; + } + return baked.get(rng.i(baked.size())); + } + + public IrisObjectPlacement asPlacement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(mode); + placement.setRotation(rotation); + placement.setClamp(clamp); + placement.setCarvingSupport(carvingSupport); + placement.setUnderwater(underwater); + placement.setTranslate(translate); + placement.setStiltSettings(stiltSettings); + placement.setVacuumSettings(vacuumSettings); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } + + public boolean isPlausible() { + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFungusCapShape.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFungusCapShape.java new file mode 100644 index 000000000..194b3baab --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFungusCapShape.java @@ -0,0 +1,35 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The silhouette of the mushroom cap. Each shape drives how the cap radius and thickness vary from its apex down to the rim, giving the fungus a distinct profile.") +public enum IrisFungusCapShape { + @Desc("A rounded hemisphere cap that bulges up and curls slightly under at the rim, the classic red toadstool dome.") + DOME, + @Desc("A wide, nearly flat umbrella that holds its full radius almost to the edge before dropping, the brown-mushroom plate look.") + FLAT, + @Desc("An upturned bowl whose rim lifts higher than its center, cupping upward like a chanterelle or funnel mushroom.") + FUNNEL, + @Desc("A tall pointed cone that tapers from a broad base to a sharp apex, the witch-hat parasol silhouette.") + CONICAL, + @Desc("A very wide but shallow slab, a low overhanging shelf-like roof that spreads far past the stem with minimal vertical rise.") + FLAT_WIDE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index afcbab6d9..c8895e8b3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -715,8 +715,7 @@ public class IrisObject extends IrisRegistrant { boolean organicFloor = config.getMode() == ObjectPlaceMode.ORGANIC_STILT; boolean ceilingHang = config.getMode() == ObjectPlaceMode.CEILING_HANG; boolean organic = organicFloor || ceilingHang; - boolean vacuuming = config.getMode() == ObjectPlaceMode.VACUUM || config.getMode() == ObjectPlaceMode.VACUUM_HIGH - || config.getMode() == ObjectPlaceMode.VACUUM_FAST || config.getMode() == ObjectPlaceMode.VACUUM_ORGANIC; + boolean vacuuming = IrisObjectVacuum.isVacuumMode(config.getMode()); boolean stilting = (config.getMode().equals(ObjectPlaceMode.STILT) || config.getMode().equals(ObjectPlaceMode.FAST_STILT) || config.getMode() == ObjectPlaceMode.MIN_STILT || config.getMode() == ObjectPlaceMode.FAST_MIN_STILT || config.getMode() == ObjectPlaceMode.CENTER_STILT || config.getMode() == ObjectPlaceMode.ERODE_STILT || organic); @@ -1365,11 +1364,13 @@ public class IrisObject extends IrisRegistrant { if (vacuuming && vacuumLowest != Integer.MAX_VALUE && placer.getEngine() != null) { BlockVector rotDim = config.getRotation().rotate(new BlockVector(getW(), getH(), getD()), spinx, spiny, spinz).clone(); - int halfW = Math.max(0, Math.abs(rotDim.getBlockX()) / 2); - int halfD = Math.max(0, Math.abs(rotDim.getBlockZ()) / 2); + int lowX = IrisObjectVacuum.footprintLow(rotDim.getBlockX()); + int highX = IrisObjectVacuum.footprintHigh(rotDim.getBlockX()); + int lowZ = IrisObjectVacuum.footprintLow(rotDim.getBlockZ()); + int highZ = IrisObjectVacuum.footprintHigh(rotDim.getBlockZ()); int centerX = x + config.getTranslate().getX(); int centerZ = z + config.getTranslate().getZ(); - vacuumTerrain(placer, config, centerX, centerZ, halfW, halfD, vacuumLowest); + vacuumTerrain(placer, config, centerX, centerZ, lowX, highX, lowZ, highZ, vacuumLowest); } if (heightmap != null) { @@ -1408,29 +1409,12 @@ public class IrisObject extends IrisRegistrant { + "(forcePlace=false, fromBottom=false, mode!=FLOATING). Skipping to protect bedrock."); } - private void vacuumTerrain(IObjectPlacer placer, IrisObjectPlacement config, int centerX, int centerZ, int halfW, int halfD, int baseY) { + private void vacuumTerrain(IObjectPlacer placer, IrisObjectPlacement config, int centerX, int centerZ, int lowX, int highX, int lowZ, int highZ, int baseY) { ObjectPlaceMode mode = config.getMode(); IrisVacuumSettings settings = config.getVacuumSettings(); - int radius; - int step; - switch (mode) { - case VACUUM_HIGH -> { - radius = 20; - step = 1; - } - case VACUUM_FAST -> { - radius = 8; - step = 2; - } - default -> { - radius = 12; - step = 1; - } - } - if (settings != null && settings.getRadius() > 0) { - radius = settings.getRadius(); - } - double falloff = settings != null ? Math.max(0.25, settings.getFalloff()) : 2.0; + int radius = IrisObjectVacuum.resolveRadius(mode, settings); + int step = IrisObjectVacuum.resolveStep(mode); + double falloff = IrisObjectVacuum.resolveFalloff(settings); int jitter = settings != null ? Math.max(0, settings.getOrganicJitter()) : 4; boolean organicEdge = mode == ObjectPlaceMode.VACUUM_ORGANIC; int meetY = baseY - 1; @@ -1439,29 +1423,21 @@ public class IrisObject extends IrisRegistrant { int worldMin = placer.getEngine().getMinHeight(); int worldMax = worldMin + placer.getEngine().getHeight() - 1; - for (int dx = -(halfW + radius); dx <= halfW + radius; dx += step) { - for (int dz = -(halfD + radius); dz <= halfD + radius; dz += step) { + for (int dx = lowX - radius; dx <= highX + radius; dx += step) { + for (int dz = lowZ - radius; dz <= highZ + radius; dz += step) { int cx = centerX + dx; int cz = centerZ + dz; - int outX = Math.max(0, Math.abs(dx) - halfW); - int outZ = Math.max(0, Math.abs(dz) - halfD); - double dist = Math.sqrt((double) (outX * outX) + (double) (outZ * outZ)); double effRadius = radius; if (organicEdge && jitter > 0) { long h = ((long) cx * 341873128712L) ^ ((long) cz * 132897987541L); double n = ((Math.abs(h) % 1000) / 1000.0) - 0.5; effRadius = Math.max(1.0, radius + (n * 2.0 * jitter)); } - if (dist > effRadius) { - continue; - } - double t = 1.0 - (dist / effRadius); - if (t <= 0) { - continue; - } - double factor = Math.pow(t, falloff); int origY = placer.getHighest(cx, cz, getLoader(), true); - int targetY = (int) Math.round(origY + ((meetY - origY) * factor)); + int targetY = IrisObjectVacuum.columnTargetY(dx, dz, lowX, highX, lowZ, highZ, effRadius, falloff, origY, meetY); + if (targetY == origY) { + continue; + } targetY = Math.max(worldMin + 1, Math.min(worldMax, targetY)); if (targetY > origY) { BlockData fill = complex != null ? complex.getRockStream().get(cx, cz) : null; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java new file mode 100644 index 000000000..b249c0113 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java @@ -0,0 +1,93 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +public final class IrisObjectVacuum { + private IrisObjectVacuum() { + } + + public static boolean isVacuumMode(ObjectPlaceMode mode) { + return mode == ObjectPlaceMode.VACUUM + || mode == ObjectPlaceMode.VACUUM_HIGH + || mode == ObjectPlaceMode.VACUUM_FAST + || mode == ObjectPlaceMode.VACUUM_ORGANIC; + } + + public static int resolveRadius(ObjectPlaceMode mode, IrisVacuumSettings settings) { + int radius = switch (mode) { + case VACUUM_HIGH -> 20; + case VACUUM_FAST -> 8; + default -> 12; + }; + if (settings != null && settings.getRadius() > 0) { + radius = settings.getRadius(); + } + return Math.max(0, radius); + } + + public static int resolveStep(ObjectPlaceMode mode) { + return mode == ObjectPlaceMode.VACUUM_FAST ? 2 : 1; + } + + public static double resolveFalloff(IrisVacuumSettings settings) { + return settings != null ? Math.max(0.25, settings.getFalloff()) : 2.0; + } + + public static int footprintLow(int dimension) { + int size = Math.abs(dimension); + return -(size / 2); + } + + public static int footprintHigh(int dimension) { + int size = Math.abs(dimension); + if (size <= 0) { + return 0; + } + return size - (size / 2) - 1; + } + + public static int outset(int delta, int low, int high) { + if (delta < low) { + return low - delta; + } + if (delta > high) { + return delta - high; + } + return 0; + } + + public static int columnTargetY(int dx, int dz, int lowX, int highX, int lowZ, int highZ, + double effectiveRadius, double falloff, int originalY, int meetY) { + int outX = outset(dx, lowX, highX); + int outZ = outset(dz, lowZ, highZ); + double distance = Math.sqrt((double) (outX * outX) + (double) (outZ * outZ)); + if (effectiveRadius <= 0) { + return distance <= 0 ? meetY : originalY; + } + if (distance > effectiveRadius) { + return originalY; + } + double t = 1.0 - (distance / effectiveRadius); + if (t <= 0) { + return originalY; + } + double factor = Math.pow(t, Math.max(0.25, falloff)); + return (int) Math.round(originalY + ((meetY - originalY) * factor)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java new file mode 100644 index 000000000..0b0adc78e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java @@ -0,0 +1,93 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.Map; + +/** + * Shared low-level helpers for building procedural objects in memory: resolving a + * single block id or a noise palette into BlockData, and assembling a raw block + * map into a centered IrisObject anchored so its y=0 layer sits one block above + * the terrain surface (same convention the tree generator uses). + */ +public final class IrisProceduralBlocks { + private IrisProceduralBlocks() { + } + + public static boolean paletteSet(IrisMaterialPalette palette) { + return palette != null && palette.getPalette() != null && !palette.getPalette().isEmpty(); + } + + public static BlockData resolve(String block, IrisMaterialPalette palette, IrisData data, int x, int y, int z, RNG paletteRng) { + if (paletteSet(palette)) { + BlockData bd = palette.get(paletteRng, x, y, z, data); + return bd == null ? null : bd.clone(); + } + if (block != null && !block.isEmpty()) { + BlockData bd = B.getOrNull(block, false); + return bd == null ? null : bd.clone(); + } + return null; + } + + public static IrisObject assemble(Map blocks) { + if (blocks == null || blocks.isEmpty()) { + return null; + } + + 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 (Vector3i v : blocks.keySet()) { + minX = Math.min(minX, v.getBlockX()); + minY = Math.min(minY, v.getBlockY()); + minZ = Math.min(minZ, v.getBlockZ()); + maxX = Math.max(maxX, v.getBlockX()); + maxY = Math.max(maxY, v.getBlockY()); + maxZ = Math.max(maxZ, v.getBlockZ()); + } + + int w = maxX - minX + 1; + int h = maxY - minY + 1; + int d = maxZ - minZ + 1; + int cx = w / 2; + int cy = h / 2; + int cz = d / 2; + + IrisObject object = new IrisObject(w, h, d); + for (Map.Entry entry : blocks.entrySet()) { + Vector3i v = entry.getKey(); + int nx = v.getBlockX() - minX - cx; + int ny = v.getBlockY() - cy + 1; + int nz = v.getBlockZ() - minZ - cz; + object.getBlocks().put(new Vector3i(nx, ny, nz), entry.getValue()); + } + + return object; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java index a11a7a41c..c5bef01fa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java @@ -31,14 +31,57 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("Procedurally generated objects placed in a biome. Unlike the objects block (which loads iob files), these are generated from scratch at world-gen time.") +@Desc("Procedurally generated objects placed in a biome or region. Unlike the objects block (which loads iob files), these are generated from scratch at world-gen time.") @Data public class IrisProceduralObjects { @ArrayType(min = 1, type = IrisProceduralTree.class) - @Desc("Procedurally generated trees placed in this biome.") + @Desc("Procedurally generated trees.") private KList trees = new KList<>(); + @ArrayType(min = 1, type = IrisRuin.class) + @Desc("Procedurally generated ruins (crumbling pillars, walls, arches, rubble).") + private KList ruins = new KList<>(); + + @ArrayType(min = 1, type = IrisFormation.class) + @Desc("Procedurally generated natural rock formations (spires, hoodoos, arches, sea stacks, boulders, basalt columns).") + private KList formations = new KList<>(); + + @ArrayType(min = 1, type = IrisCoral.class) + @Desc("Procedurally generated underwater coral structures.") + private KList coral = new KList<>(); + + @ArrayType(min = 1, type = IrisFungus.class) + @Desc("Procedurally generated mushrooms and shelf fungi.") + private KList fungi = new KList<>(); + + @ArrayType(min = 1, type = IrisCrystal.class) + @Desc("Procedurally generated crystal clusters (usually placed in caves).") + private KList crystals = new KList<>(); + + public KList getAllPlacements() { + KList all = new KList<>(); + if (trees != null) { + all.addAll(trees); + } + if (ruins != null) { + all.addAll(ruins); + } + if (formations != null) { + all.addAll(formations); + } + if (coral != null) { + all.addAll(coral); + } + if (fungi != null) { + all.addAll(fungi); + } + if (crystals != null) { + all.addAll(crystals); + } + return all; + } + public boolean isEmpty() { - return trees == null || trees.isEmpty(); + return getAllPlacements().isEmpty(); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralPlacement.java new file mode 100644 index 000000000..d0a8ed67c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralPlacement.java @@ -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 . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.volmlib.util.math.RNG; + +/** + * A procedurally generated, placeable thing in a biome or region (trees, ruins, + * formations, coral, fungi, crystals). Implementations bake a deterministic pool + * of in-memory IrisObject variants and expose the placement controls the engine + * needs to scatter them, exactly like an object placement but generated from + * scratch instead of loaded from an iob file. + */ +public interface IrisProceduralPlacement { + String getName(); + + double getChance(); + + int getDensity(); + + boolean isPlausible(); + + IrisObjectPlacement asPlacement(); + + IrisObject getVariantObject(IrisData data, RNG rng); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java index 00a36c6f4..8e1815fbf 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java @@ -40,7 +40,7 @@ import lombok.experimental.Accessors; @AllArgsConstructor @Desc("A single procedurally generated tree. Iris bakes a pool of deterministic variants from these settings and scatters them at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file.") @Data -public class IrisProceduralTree { +public class IrisProceduralTree implements IrisProceduralPlacement { private final transient AtomicCache> variantCache = new AtomicCache<>(); @Desc("A human readable name used in logs and as the variant load key.") @@ -81,6 +81,15 @@ public class IrisProceduralTree { @Desc("If true, the tree anchors on the terrain height ignoring the water surface.") private boolean underwater = false; + @Desc("Translate (offset) this placement along each axis; for example set a negative y to sink it into the ground.") + private IrisObjectTranslate translate = new IrisObjectTranslate(); + + @Desc("Settings for the stilt place modes (STILT, MIN_STILT, FAST_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT).") + private IrisStiltSettings stiltSettings; + + @Desc("Settings for the vacuum place modes (VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC).") + private IrisVacuumSettings vacuumSettings; + @Required @Desc("The trunk (log) block, e.g. minecraft:oak_log. Ignored when trunkPalette is set.") private String trunk = "minecraft:oak_log"; @@ -289,6 +298,9 @@ public class IrisProceduralTree { placement.setClamp(clamp); placement.setCarvingSupport(carvingSupport); placement.setUnderwater(underwater); + placement.setTranslate(translate); + placement.setStiltSettings(stiltSettings); + placement.setVacuumSettings(vacuumSettings); placement.setChance(chance); placement.setDensity(density); return placement; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 747db19f8..f42029d0f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -92,6 +92,8 @@ public class IrisRegion extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisObjectPlacement.class) @Desc("Objects define what schematics (iob files) iris will place in this region") private KList objects = new KList<>(); + @Desc("Procedural objects (trees, ruins, formations, coral, fungi, crystals) iris generates from scratch and places across this region") + private IrisProceduralObjects proceduralObjects = new IrisProceduralObjects(); @ArrayType(min = 1, type = IrisStructurePlacement.class) @Desc("Structures define jigsaw or vanilla/datapack structures iris will place in this region") private KList structures = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRuin.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRuin.java new file mode 100644 index 000000000..c24f1d117 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRuin.java @@ -0,0 +1,204 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.engine.object.ruin.RuinGenerator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("ruin") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A single procedurally generated ruin: a crumbling man-made structure (pillar, wall, arch, foundation slab, or rubble pile). Iris bakes a pool of deterministic variants from these settings and scatters them at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file. Weathering swaps blocks for mossy or cracked variants by noise, erosion deletes blocks so the shape reads as collapsed, and a buried fraction sinks the ruin into the terrain.") +@Data +public class IrisRuin implements IrisProceduralPlacement { + private final transient AtomicCache> variantCache = new AtomicCache<>(); + + @Desc("A human readable name used in logs and as the variant load key (procedural/#).") + private String name = "ruin"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance (0-1) per chunk for this ruin to attempt placement. Use density to attempt more than one per chunk once the chance check passes.") + private double chance = 0.05; + + @MinNumber(1) + @Desc("If the chance check passes, attempt this many placements in the chunk. Defaults to a single ruin per qualifying chunk.") + private int density = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("How many distinct variants to pre-bake for this ruin. Higher means more variety (different heights, gap patterns, erosion seeds) at a small memory cost.") + private int variants = 6; + + @Desc("The base seed for deterministic generation. The same seed and settings always bake the same variants. Every random choice (size, weathering mask, erosion mask, accents) is derived from this.") + private long seed = 1337; + + @Desc("The placement mode used to anchor the ruin to the terrain. MIN_HEIGHT plants the lowest footprint corner on the ground (good for slabs and rubble on slopes); CENTER_HEIGHT averages the footprint (good for tall pillars and arches).") + private ObjectPlaceMode mode = ObjectPlaceMode.MIN_HEIGHT; + + @Desc("Rotate this ruin's placement so variants do not all face the same way.") + private IrisObjectRotation rotation = new IrisObjectRotation(); + + @Desc("Limit the max or min terrain height at which this ruin may place.") + private IrisObjectLimit clamp = new IrisObjectLimit(); + + @Desc("Whether this ruin may place on the terrain surface, under carvings (caves), or both. SURFACE_ONLY keeps ruins above ground.") + private CarvingMode carvingSupport = CarvingMode.SURFACE_ONLY; + + @Desc("If true the ruin anchors on the terrain height ignoring the water surface, so it can sit submerged. If false (default) it anchors to the surface and avoids water.") + private boolean underwater = false; + + @Desc("Translate (offset) this placement along each axis; for example set a negative y to sink it into the ground.") + private IrisObjectTranslate translate = new IrisObjectTranslate(); + + @Desc("Settings for the stilt place modes (STILT, MIN_STILT, FAST_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT).") + private IrisStiltSettings stiltSettings; + + @Desc("Settings for the vacuum place modes (VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC).") + private IrisVacuumSettings vacuumSettings; + + @Required + @Desc("The primary structural block, e.g. minecraft:cobblestone or minecraft:stone_bricks. Ignored when blockPalette is set. This is the bulk material of the ruin before weathering swaps some of it out.") + private String block = "minecraft:cobblestone"; + + @Desc("A noise-driven palette for the primary block. When set this overrides the single block, letting the structure mix materials by noise. Palette wins over the block string via IrisProceduralBlocks.resolve.") + private IrisMaterialPalette blockPalette = null; + + @Desc("The overall silhouette of the ruin: PILLAR (broken column), WALL (gapped segment), ARCH (two legs plus a curved span), FLOOR_SLAB (foundation patch), or RUBBLE (low scattered pile).") + private IrisRuinForm form = IrisRuinForm.PILLAR; + + @MinNumber(1) + @Desc("Minimum structure height in blocks (PILLAR/WALL/ARCH height, slab thickness for FLOOR_SLAB, mound height for RUBBLE). Variants interpolate between this and heightMax.") + private int heightMin = 4; + + @MinNumber(1) + @Desc("Maximum structure height in blocks. Variants interpolate between heightMin and this so taller and shorter ruins coexist.") + private int heightMax = 9; + + @MinNumber(1) + @Desc("Minimum footprint width (X axis) in blocks: column/leg thickness for PILLAR/ARCH, wall thickness for WALL, slab width for FLOOR_SLAB, blob width for RUBBLE.") + private int widthMin = 1; + + @MinNumber(1) + @Desc("Maximum footprint width (X axis) in blocks. Variants interpolate between widthMin and this.") + private int widthMax = 3; + + @MinNumber(1) + @Desc("Minimum footprint length (Z axis) in blocks: wall run length, arch leg spacing, slab length, rubble blob length.") + private int lengthMin = 3; + + @MinNumber(1) + @Desc("Maximum footprint length (Z axis) in blocks. Variants interpolate between lengthMin and this.") + private int lengthMax = 7; + + @Desc("A palette of weathered variants (mossy/cracked block ids) blended over the structure by noise. When set this drives weathering; otherwise weatheredBlock is used.") + private IrisMaterialPalette weatheringPalette = null; + + @Desc("A single weathered block id used when weatheringPalette is not set, e.g. minecraft:mossy_cobblestone. Applied to a noise-selected fraction of the structure, biased toward the lower rows.") + private String weatheredBlock = "minecraft:mossy_cobblestone"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How much of the structure is replaced by weathered blocks (0 none, 1 nearly all). The replacement mask is value-noise driven and weighted so lower rows weather more than upper rows, mimicking moss climbing from the ground.") + private double mossiness = 0.45; + + @MinNumber(0) + @MaxNumber(8) + @Desc("The scale of the weathering noise. Higher values produce smaller, busier moss/crack patches; lower values produce broad continuous weathered zones.") + private double weatheringScale = 1.0; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How crumbled the ruin is (0 intact, 1 mostly gone). Blocks whose value-noise falls below this threshold are deleted, so the shape reads as collapsed. Structural integrity is preserved: the bottom row and core legs are never eroded away.") + private double erosion = 0.25; + + @MinNumber(0) + @MaxNumber(8) + @Desc("The scale of the erosion noise. Higher values knock out small speckled holes; lower values carve larger missing chunks out of the silhouette.") + private double erosionScale = 1.5; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How far the structure extends below the surface as a fraction of its height (0 sits fully on top, 1 sinks it a full height into the ground). The buried rows live at negative y so the ruin reads as settled and partly swallowed by the terrain.") + private double buriedFraction = 0.2; + + @ArrayType(min = 1, type = IrisRuinDecorator.class) + @Desc("Accent decorators applied after the ruin is built and eroded (moss carpets and lanterns on broken tops, vines and lichen on the faces, rubble and growth scattered around the base).") + private KList accents = new KList<>(); + + public KList getVariantObjects(IrisData data) { + return variantCache.aquire(() -> { + KList baked = new KList<>(); + int count = Math.max(1, variants); + + for (int i = 0; i < count; i++) { + IrisObject object = RuinGenerator.generate(this, i, new RNG(seed + (i * 7919L)), data); + if (object == null || object.getBlocks().isEmpty()) { + continue; + } + object.setLoadKey("procedural/" + name + "#" + i); + object.setLoader(data); + baked.add(object); + } + + return baked; + }); + } + + public IrisObject getVariantObject(IrisData data, RNG rng) { + KList baked = getVariantObjects(data); + if (baked.isEmpty()) { + return null; + } + return baked.get(rng.i(baked.size())); + } + + public IrisObjectPlacement asPlacement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(mode); + placement.setRotation(rotation); + placement.setClamp(clamp); + placement.setCarvingSupport(carvingSupport); + placement.setUnderwater(underwater); + placement.setTranslate(translate); + placement.setStiltSettings(stiltSettings); + placement.setVacuumSettings(vacuumSettings); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } + + public boolean isPlausible() { + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecorator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecorator.java new file mode 100644 index 000000000..41e398d33 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecorator.java @@ -0,0 +1,56 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("ruin-decorator") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("An accent block applied to a generated ruin after it is built and eroded, such as moss carpets on broken tops, vines on the faces, or rubble scattered around the base.") +@Data +public class IrisRuinDecorator { + @Desc("Where on the ruin this accent is placed. TOP sits on the highest block of each column, SURFACE clings to air-facing vertical faces, BASE_SCATTER rings the ground footprint around the base.") + private IrisRuinDecoratorTarget target = IrisRuinDecoratorTarget.TOP; + + @Required + @Desc("The block id to place, e.g. minecraft:moss_carpet or minecraft:vine. Ignored when palette is set.") + private String block = ""; + + @Desc("A noise-driven palette for this decorator. When set this overrides the single block, letting the accent mix blocks by noise. Resolved through IrisProceduralBlocks.resolve so the palette wins over the string.") + private IrisMaterialPalette palette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance (0-1) per candidate position for this decorator to actually place. 1 covers every candidate, lower values leave sparse, patchy accents.") + private double chance = 0.4; + + @MinNumber(1) + @Desc("For the BASE_SCATTER target, how many blocks beyond the ruin footprint the scatter ring extends outward in each direction. Higher values fling rubble and growth further from the structure.") + private int scatterRadius = 2; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecoratorTarget.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecoratorTarget.java new file mode 100644 index 000000000..04c373290 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecoratorTarget.java @@ -0,0 +1,31 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Where a ruin accent block is placed after the ruin has been built and eroded.") +public enum IrisRuinDecoratorTarget { + @Desc("On top of the highest surviving block in each column (moss carpets, snow, sculk, lanterns on broken tops).") + TOP, + @Desc("On air-facing vertical sides of any surviving block (vines, glow lichen, climbing growth on the ruin faces).") + SURFACE, + @Desc("Scattered across the ground footprint around the y=0 base ring, wider than the structure itself (loose rubble, mushrooms, grass tufts ringing the ruin).") + BASE_SCATTER +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRuinForm.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRuinForm.java new file mode 100644 index 000000000..313ece19a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRuinForm.java @@ -0,0 +1,35 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The overall silhouette of a procedural ruin. Each form rasterizes a different crumbling man-made shape from the same size, weathering, and erosion settings.") +public enum IrisRuinForm { + @Desc("A broken vertical column. A square trunk (widthMin..widthMax footprint) rises to a randomized height then snaps off with a jagged crown, leaving a toppled stub. Use for ancient pillars and obelisk stumps.") + PILLAR, + @Desc("A standing wall segment. A flat slab spanning lengthMin..lengthMax long and heightMin..heightMax tall, thickness widthMin..widthMax, punched through with noise-driven gaps and a ragged top edge. Use for collapsed building sides and fortifications.") + WALL, + @Desc("A freestanding arch. Two legs spaced by lengthMin..lengthMax joined at the top by a rasterized semicircular span (the lintel), so the structure literally arches. Use for gateways, aqueduct bays, and ruined doorways.") + ARCH, + @Desc("A flat foundation patch. A single (or few) block-thick slab covering a widthMin..lengthMax footprint at ground level, heavily eroded into an irregular outline. Use for floor remnants, plazas, and building foundations.") + FLOOR_SLAB, + @Desc("A low scattered debris pile. A noise blob of partial blocks mounded near the ground, tallest at the center and thinning toward the edges. Use for collapsed-structure rubble and scree.") + RUBBLE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/coral/CoralCanvas.java b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralCanvas.java new file mode 100644 index 000000000..b9462f457 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralCanvas.java @@ -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 . + */ + +package art.arcane.iris.engine.object.coral; + +import java.util.HashMap; +import java.util.Map; + +public final class CoralCanvas { + public enum Role { + STRUCTURE, + TIP + } + + private static final long BIAS = 1L << 20; + private static final long MASK = (1L << 21) - 1L; + + private final Map cells = new HashMap<>(); + + public void set(int x, int y, int z, Role role) { + if (y < 0) { + return; + } + long key = encode(x, y, z); + Role existing = cells.get(key); + if (existing == Role.TIP && role == Role.STRUCTURE) { + return; + } + cells.put(key, role); + } + + public Map getCells() { + return cells; + } + + public static long encode(int x, int y, int z) { + long ex = (x + BIAS) & MASK; + long ey = (y + BIAS) & MASK; + long ez = (z + BIAS) & MASK; + return (ex << 42) | (ey << 21) | ez; + } + + public static int[] decode(long key) { + int ez = (int) ((key & MASK) - BIAS); + int ey = (int) (((key >> 21) & MASK) - BIAS); + int ex = (int) (((key >> 42) & MASK) - BIAS); + return new int[]{ex, ey, ez}; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java new file mode 100644 index 000000000..e9d018b57 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java @@ -0,0 +1,261 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.coral; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisCoral; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.object.tree.TreeFunctions; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Waterlogged; + +import java.util.HashMap; +import java.util.Map; + +public final class CoralGenerator { + private static final double GOLDEN_ANGLE = 137.50776405003785; + + private CoralGenerator() { + } + + public static IrisObject generate(IrisCoral coral, int variantIndex, RNG rng, IrisData data) { + int lo = Math.min(coral.getHeightMin(), coral.getHeightMax()); + int hi = Math.max(coral.getHeightMin(), coral.getHeightMax()); + int height = Math.max(2, rng.i(lo, hi + 1)); + long shapeSeed = rng.getSeed() + variantIndex * 31L; + + CoralCanvas canvas = new CoralCanvas(); + switch (coral.getForm()) { + case BRANCHING -> buildBranching(canvas, coral, height, shapeSeed, rng); + case FAN -> buildFan(canvas, coral, height, shapeSeed); + case BRAIN -> buildBrain(canvas, coral, height, shapeSeed); + case PILLAR -> buildPillar(canvas, coral, height, shapeSeed); + case TENDRIL -> buildTendril(canvas, coral, height, shapeSeed, rng); + } + + Map resolved = resolve(canvas, coral, data); + return IrisProceduralBlocks.assemble(resolved); + } + + private static void buildBranching(CoralCanvas canvas, IrisCoral coral, int height, long seed, RNG rng) { + int stalkTop = Math.max(1, (int) Math.round(height * 0.5)); + for (int y = 0; y < stalkTop; y++) { + placeWavy(canvas, coral, 0, y, 0, y, seed, CoralCanvas.Role.STRUCTURE); + } + + int count = Math.max(1, coral.getBranchCount()); + for (int b = 0; b < count; b++) { + double az = branchAzimuth(coral, b, count, rng); + int startY = stalkTop - 1 + (int) Math.round((b % 2) * 1.0); + double[] tip = rasterizeArm(canvas, coral, 0, startY, 0, az, + coral.getBranchElevation(), coral.getBranchLength(), seed + b * 131L); + + placeTipCluster(canvas, coral, (int) Math.round(tip[0]), (int) Math.round(tip[1]), (int) Math.round(tip[2]), + coral.getTipClusterRadius(), seed + b * 977L); + + if (coral.isSubBranches()) { + int subCount = Math.max(1, coral.getSubBranchCount()); + for (int s = 0; s < subCount; s++) { + double subAz = az + (s - (subCount - 1) / 2.0) * 35.0; + double subLen = coral.getBranchLength() * coral.getSubBranchScale(); + double[] subTip = rasterizeArm(canvas, coral, + (int) Math.round(tip[0]), (int) Math.round(tip[1]), (int) Math.round(tip[2]), + subAz, coral.getBranchElevation() + 10.0, subLen, seed + b * 131L + s * 17L + 5000L); + placeTipCluster(canvas, coral, (int) Math.round(subTip[0]), (int) Math.round(subTip[1]), (int) Math.round(subTip[2]), + Math.max(0, coral.getTipClusterRadius() - 1), seed + b * 977L + s * 53L); + } + } + } + } + + private static void buildFan(CoralCanvas canvas, IrisCoral coral, int height, long seed) { + int half = Math.max(1, coral.getFanWidth()); + for (int y = 0; y < height; y++) { + double t = y / (double) Math.max(1, height - 1); + double profile = Math.sin(Math.PI * t); + int w = (int) Math.round(half * profile); + for (int dx = -w; dx <= w; dx++) { + canvas.set(dx, y, 0, CoralCanvas.Role.STRUCTURE); + } + } + for (int dx = -half; dx <= half; dx++) { + placeTip(canvas, coral, dx, height - 1, 0, seed + dx); + } + } + + private static void buildBrain(CoralCanvas canvas, IrisCoral coral, int height, long seed) { + int r = Math.max(1, coral.getBrainRadius()); + int ry = Math.max(1, Math.min(r, (int) Math.round(height * 0.6))); + double rough = coral.getBrainRoughness(); + for (int dx = -r; dx <= r; dx++) { + for (int dz = -r; dz <= r; dz++) { + for (int dy = 0; dy <= ry * 2; dy++) { + double nx = dx / (double) r; + double ny = (dy - ry) / (double) ry; + double nz = dz / (double) r; + double d = nx * nx + ny * ny + nz * nz; + double wobble = (TreeFunctions.valueNoise3D(dx, dy, dz, seed) - 0.5) * 2.0 * rough; + if (d <= 1.0 + wobble) { + canvas.set(dx, dy, dz, CoralCanvas.Role.STRUCTURE); + } + } + } + } + } + + private static void buildPillar(CoralCanvas canvas, IrisCoral coral, int height, long seed) { + int r = Math.max(1, coral.getPillarRadius()); + for (int y = 0; y < height; y++) { + for (int dx = -r; dx <= r; dx++) { + for (int dz = -r; dz <= r; dz++) { + if (dx * dx + dz * dz <= r * r) { + canvas.set(dx, y, dz, CoralCanvas.Role.STRUCTURE); + } + } + } + } + placeTipCluster(canvas, coral, 0, height - 1, 0, coral.getTipClusterRadius(), seed); + } + + private static void buildTendril(CoralCanvas canvas, IrisCoral coral, int height, long seed, RNG rng) { + int count = Math.max(1, coral.getTendrilCount()); + double radius = Math.max(0.0, coral.getSpread()); + for (int c = 0; c < count; c++) { + double az = Math.toRadians(c * (360.0 / count)); + double bx = Math.cos(az) * radius * 0.5; + double bz = Math.sin(az) * radius * 0.5; + int tx = (int) Math.round(bx); + int tz = (int) Math.round(bz); + int top = 0; + for (int y = 0; y < height; y++) { + placeWavy(canvas, coral, tx, y, tz, y * (c + 1), seed + c * 311L, CoralCanvas.Role.STRUCTURE); + top = y; + } + placeTip(canvas, coral, tx, top, tz, seed + c * 311L); + } + } + + private static double[] rasterizeArm(CoralCanvas canvas, IrisCoral coral, int ox, int oy, int oz, + double azimuthDeg, double elevationDeg, double length, long seed) { + double azRad = Math.toRadians(azimuthDeg); + double elRad = Math.toRadians(elevationDeg); + double dx = length * Math.cos(elRad) * Math.sin(azRad); + double dy = length * Math.sin(elRad); + double dz = length * Math.cos(elRad) * Math.cos(azRad); + int steps = Math.max(1, (int) Math.round(Math.max(Math.abs(dy), Math.max(Math.abs(dx), Math.abs(dz))))); + + double[] tip = new double[]{ox, oy, oz}; + for (int i = 0; i <= steps; i++) { + double t = i / (double) steps; + double sway = swayOffset(coral, t * length, seed); + int x = (int) Math.round(ox + dx * t + sway); + int y = (int) Math.round(oy + dy * t); + int z = (int) Math.round(oz + dz * t - sway); + canvas.set(x, y, z, CoralCanvas.Role.STRUCTURE); + tip = new double[]{x, y, z}; + } + return tip; + } + + private static void placeWavy(CoralCanvas canvas, IrisCoral coral, int x, int y, int z, double phase, long seed, CoralCanvas.Role role) { + double sway = swayOffset(coral, phase, seed); + int sx = (int) Math.round(sway); + int sz = (int) Math.round(swayOffset(coral, phase + 53.0, seed + 7L)); + canvas.set(x + sx, y, z + sz, role); + } + + private static double swayOffset(IrisCoral coral, double phase, long seed) { + double amount = Math.max(0.0, Math.min(1.0, coral.getSway())); + if (amount <= 0.0) { + return 0.0; + } + double n = TreeFunctions.valueNoise1D(phase * 0.35, seed) - 0.5; + return n * 2.0 * amount * (1.0 + coral.getSpread() * 0.25); + } + + private static void placeTipCluster(CoralCanvas canvas, IrisCoral coral, int cx, int cy, int cz, int radius, long seed) { + if (radius <= 0) { + placeTip(canvas, coral, cx, cy, cz, seed); + return; + } + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + for (int dz = -radius; dz <= radius; dz++) { + if (dx * dx + dy * dy + dz * dz <= radius * radius) { + canvas.set(cx + dx, cy + dy, cz + dz, CoralCanvas.Role.STRUCTURE); + } + } + } + } + placeTip(canvas, coral, cx, cy + radius + 1, cz, seed); + } + + private static void placeTip(CoralCanvas canvas, IrisCoral coral, int x, int y, int z, long seed) { + if (!hasTip(coral)) { + return; + } + if (TreeFunctions.valueNoise1D(x * 31.0 + y * 17.0 + z * 13.0, seed) <= coral.getTipChance()) { + canvas.set(x, y, z, CoralCanvas.Role.TIP); + } + } + + private static boolean hasTip(IrisCoral coral) { + return IrisProceduralBlocks.paletteSet(coral.getTipPalette()) + || (coral.getTipBlock() != null && !coral.getTipBlock().isEmpty()); + } + + private static double branchAzimuth(IrisCoral coral, int index, int count, RNG rng) { + return switch (coral.getBranchAzimuth()) { + case GOLDEN_ANGLE -> index * GOLDEN_ANGLE; + case EVEN -> index * (360.0 / count); + case RANDOM -> rng.d(0.0, 360.0); + }; + } + + private static Map resolve(CoralCanvas canvas, IrisCoral coral, IrisData data) { + Map out = new HashMap<>(); + RNG paletteRng = new RNG(coral.getSeed()); + for (Map.Entry entry : canvas.getCells().entrySet()) { + int[] xyz = CoralCanvas.decode(entry.getKey()); + int x = xyz[0]; + int y = xyz[1]; + int z = xyz[2]; + BlockData bd; + if (entry.getValue() == CoralCanvas.Role.TIP) { + bd = IrisProceduralBlocks.resolve(coral.getTipBlock(), coral.getTipPalette(), data, x, y, z, paletteRng); + if (bd == null) { + bd = IrisProceduralBlocks.resolve(coral.getBlock(), coral.getBlockPalette(), data, x, y, z, paletteRng); + } + } else { + bd = IrisProceduralBlocks.resolve(coral.getBlock(), coral.getBlockPalette(), data, x, y, z, paletteRng); + } + if (bd == null) { + continue; + } + if (coral.isWaterlogged() && bd instanceof Waterlogged waterlogged) { + waterlogged.setWaterlogged(true); + } + out.put(new Vector3i(x, y, z), bd); + } + return out; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalBaseBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalBaseBuilder.java new file mode 100644 index 000000000..764003979 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalBaseBuilder.java @@ -0,0 +1,49 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.crystal; + +import art.arcane.iris.engine.object.IrisCrystal; +import art.arcane.iris.engine.object.tree.TreeFunctions; + +public final class CrystalBaseBuilder { + private CrystalBaseBuilder() { + } + + public static void build(CrystalCanvas canvas, IrisCrystal crystal, long seed) { + double radius = crystal.getBaseRadius(); + if (radius <= 0.0) { + canvas.set(0, 0, 0, CrystalRole.BASE); + return; + } + + double noiseStrength = crystal.getBaseNoise(); + int reach = (int) Math.ceil(radius + 1.0); + for (int x = -reach; x <= reach; x++) { + for (int y = -reach; y <= reach; y++) { + for (int z = -reach; z <= reach; z++) { + double distance = Math.sqrt((double) x * x + (double) y * y + (double) z * z); + double wobble = (TreeFunctions.valueNoise3D(x, y, z, seed) - 0.5) * 2.0 * noiseStrength * radius; + if (distance <= radius + wobble) { + canvas.set(x, y, z, CrystalRole.BASE); + } + } + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalCanvas.java b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalCanvas.java new file mode 100644 index 000000000..ca7283fe0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalCanvas.java @@ -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 . + */ + +package art.arcane.iris.engine.object.crystal; + +import art.arcane.iris.util.common.math.Vector3i; + +import java.util.HashMap; +import java.util.Map; + +public final class CrystalCanvas { + private final Map cells = new HashMap<>(); + + public void set(int x, int y, int z, CrystalRole role) { + Vector3i key = new Vector3i(x, y, z); + CrystalRole existing = cells.get(key); + if (existing == null || rank(role) > rank(existing)) { + cells.put(key, role); + } + } + + public boolean has(int x, int y, int z) { + return cells.containsKey(new Vector3i(x, y, z)); + } + + public Map getCells() { + return cells; + } + + private static int rank(CrystalRole role) { + return switch (role) { + case BASE -> 0; + case SHARD -> 1; + case TIP -> 2; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGenerator.java new file mode 100644 index 000000000..693fed3e0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGenerator.java @@ -0,0 +1,111 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.crystal; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisCrystal; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.HashMap; +import java.util.Map; + +public final class CrystalGenerator { + private CrystalGenerator() { + } + + public static IrisObject generate(IrisCrystal crystal, int variantIndex, RNG rng, IrisData data) { + CrystalCanvas canvas = new CrystalCanvas(); + long structureSeed = rng.getSeed(); + + CrystalBaseBuilder.build(canvas, crystal, structureSeed + 977L); + CrystalShardBuilder.build(canvas, crystal, rng); + + Map resolved = new HashMap<>(); + RNG paletteRng = new RNG(crystal.getSeed()); + RNG tipRng = new RNG(crystal.getSeed() + (variantIndex * 31337L) + 4099L); + + for (Map.Entry entry : canvas.getCells().entrySet()) { + Vector3i position = entry.getKey(); + int x = position.getBlockX(); + int y = position.getBlockY(); + int z = position.getBlockZ(); + BlockData blockData = resolveRole(crystal, entry.getValue(), data, x, y, z, paletteRng, tipRng); + if (blockData == null) { + continue; + } + resolved.put(position, blockData); + } + + return IrisProceduralBlocks.assemble(resolved); + } + + private static BlockData resolveRole(IrisCrystal crystal, CrystalRole role, IrisData data, int x, int y, int z, RNG paletteRng, RNG tipRng) { + return switch (role) { + case BASE -> resolveBase(crystal, data, x, y, z, paletteRng); + case SHARD -> resolveShard(crystal, data, x, y, z, paletteRng); + case TIP -> resolveTip(crystal, data, x, y, z, paletteRng, tipRng); + }; + } + + private static BlockData resolveBase(IrisCrystal crystal, IrisData data, int x, int y, int z, RNG paletteRng) { + boolean hasBase = IrisProceduralBlocks.paletteSet(crystal.getBasePalette()) + || (crystal.getBaseBlock() != null && !crystal.getBaseBlock().isEmpty()); + if (hasBase) { + BlockData base = IrisProceduralBlocks.resolve(crystal.getBaseBlock(), crystal.getBasePalette(), data, x, y, z, paletteRng); + if (base != null) { + return base; + } + } + return resolveShard(crystal, data, x, y, z, paletteRng); + } + + private static BlockData resolveShard(IrisCrystal crystal, IrisData data, int x, int y, int z, RNG paletteRng) { + return IrisProceduralBlocks.resolve(crystal.getBlock(), crystal.getBlockPalette(), data, x, y, z, paletteRng); + } + + private static BlockData resolveTip(IrisCrystal crystal, IrisData data, int x, int y, int z, RNG paletteRng, RNG tipRng) { + boolean hasTip = IrisProceduralBlocks.paletteSet(crystal.getTipPalette()) + || (crystal.getTipBlock() != null && !crystal.getTipBlock().isEmpty()); + + if (hasTip) { + if (tipRng.chance(crystal.getTipChance())) { + BlockData tip = IrisProceduralBlocks.resolve(crystal.getTipBlock(), crystal.getTipPalette(), data, x, y, z, paletteRng); + if (tip != null) { + return tip; + } + } + return resolveShard(crystal, data, x, y, z, paletteRng); + } + + if (crystal.isGlow() && crystal.getGlowBlock() != null && !crystal.getGlowBlock().isEmpty()) { + if (tipRng.chance(crystal.getTipChance())) { + BlockData glow = IrisProceduralBlocks.resolve(crystal.getGlowBlock(), null, data, x, y, z, paletteRng); + if (glow != null) { + return glow; + } + } + } + + return resolveShard(crystal, data, x, y, z, paletteRng); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGeometry.java b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGeometry.java new file mode 100644 index 000000000..2bbe409d6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGeometry.java @@ -0,0 +1,84 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.crystal; + +import art.arcane.iris.engine.object.IrisCrystalSurface; + +public final class CrystalGeometry { + private CrystalGeometry() { + } + + public static double[] surfaceNormal(IrisCrystalSurface surface) { + return switch (surface) { + case FLOOR -> new double[]{0.0, 1.0, 0.0}; + case CEILING -> new double[]{0.0, -1.0, 0.0}; + case WALL -> { + double len = Math.sqrt(1.0 + 0.5 * 0.5); + yield new double[]{1.0 / len, 0.5 / len, 0.0}; + } + }; + } + + public static double[] orthonormalBasisU(double[] normal) { + double[] reference = Math.abs(normal[1]) > 0.99 ? new double[]{1.0, 0.0, 0.0} : new double[]{0.0, 1.0, 0.0}; + double[] u = cross(reference, normal); + normalize(u); + return u; + } + + public static double[] orthonormalBasisV(double[] normal, double[] u) { + double[] v = cross(normal, u); + normalize(v); + return v; + } + + public static double[] coneDirection(double[] normal, double[] u, double[] v, double coneAngleRadians, double azimuthRadians) { + double sin = Math.sin(coneAngleRadians); + double cos = Math.cos(coneAngleRadians); + double ca = Math.cos(azimuthRadians); + double sa = Math.sin(azimuthRadians); + double[] dir = new double[3]; + for (int i = 0; i < 3; i++) { + dir[i] = cos * normal[i] + sin * (ca * u[i] + sa * v[i]); + } + normalize(dir); + return dir; + } + + public static double[] cross(double[] a, double[] b) { + return new double[]{ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + }; + } + + public static void normalize(double[] vec) { + double len = Math.sqrt(vec[0] * vec[0] + vec[1] * vec[1] + vec[2] * vec[2]); + if (len < 1e-9) { + vec[0] = 0.0; + vec[1] = 1.0; + vec[2] = 0.0; + return; + } + vec[0] /= len; + vec[1] /= len; + vec[2] /= len; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalRole.java b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalRole.java new file mode 100644 index 000000000..da6fa15c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalRole.java @@ -0,0 +1,25 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.crystal; + +public enum CrystalRole { + BASE, + SHARD, + TIP +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalShardBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalShardBuilder.java new file mode 100644 index 000000000..c0b0e8cca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalShardBuilder.java @@ -0,0 +1,125 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.crystal; + +import art.arcane.iris.engine.object.IrisCrystal; +import art.arcane.iris.engine.object.IrisCrystalDistribution; +import art.arcane.volmlib.util.math.RNG; + +public final class CrystalShardBuilder { + private static final double GOLDEN_ANGLE = 137.50776405003785; + + private CrystalShardBuilder() { + } + + public static void build(CrystalCanvas canvas, IrisCrystal crystal, RNG rng) { + double[] normal = CrystalGeometry.surfaceNormal(crystal.getGrowthSurface()); + double[] u = CrystalGeometry.orthonormalBasisU(normal); + double[] v = CrystalGeometry.orthonormalBasisV(normal, u); + + int countLo = Math.min(crystal.getShardCountMin(), crystal.getShardCountMax()); + int countHi = Math.max(crystal.getShardCountMin(), crystal.getShardCountMax()); + int shardCount = Math.max(1, rng.i(countLo, countHi + 1)); + + int lengthLo = Math.min(crystal.getShardLengthMin(), crystal.getShardLengthMax()); + int lengthHi = Math.max(crystal.getShardLengthMin(), crystal.getShardLengthMax()); + + double spreadRadians = Math.toRadians(Math.max(0.0, Math.min(90.0, crystal.getSpreadAngle()))); + double jitterAmount = Math.max(0.0, Math.min(1.0, crystal.getJitter())); + double emitRadius = Math.max(0.0, crystal.getBaseRadius() * 0.6); + + for (int i = 0; i < shardCount; i++) { + double azimuth = azimuth(crystal.getDistribution(), i, rng); + azimuth += rng.d(-Math.PI, Math.PI) * jitterAmount * 0.5; + + double cone = spreadRadians * fanFraction(shardCount, i, rng); + cone += rng.d(-spreadRadians, spreadRadians) * jitterAmount * 0.5; + cone = Math.max(0.0, Math.min(Math.PI / 2.0, cone)); + + double[] direction = CrystalGeometry.coneDirection(normal, u, v, cone, azimuth); + + double ox = normal[0] * emitRadius + u[0] * Math.cos(azimuth) * emitRadius + v[0] * Math.sin(azimuth) * emitRadius; + double oy = normal[1] * emitRadius + u[1] * Math.cos(azimuth) * emitRadius + v[1] * Math.sin(azimuth) * emitRadius; + double oz = normal[2] * emitRadius + u[2] * Math.cos(azimuth) * emitRadius + v[2] * Math.sin(azimuth) * emitRadius; + + int length = Math.max(1, rng.i(lengthLo, lengthHi + 1)); + rasterizeShard(canvas, crystal, ox, oy, oz, direction, length); + } + } + + private static void rasterizeShard(CrystalCanvas canvas, IrisCrystal crystal, double ox, double oy, double oz, double[] direction, int length) { + double baseRadius = Math.max(0.5, crystal.getShardBaseRadius()); + double taper = Math.max(0.0, Math.min(1.0, crystal.getShardTaper())); + + int samples = Math.max(1, length * 3); + + for (int s = 0; s <= samples; s++) { + double t = s / (double) samples; + double cx = ox + direction[0] * length * t; + double cy = oy + direction[1] * length * t; + double cz = oz + direction[2] * length * t; + + double radius = baseRadius * (1.0 - taper * t); + if (t >= 1.0) { + radius = 0.0; + } + radius = Math.max(0.0, radius); + + int reach = (int) Math.floor(radius); + int centerX = (int) Math.round(cx); + int centerY = (int) Math.round(cy); + int centerZ = (int) Math.round(cz); + + if (reach <= 0) { + canvas.set(centerX, centerY, centerZ, CrystalRole.SHARD); + } else { + for (int dx = -reach; dx <= reach; dx++) { + for (int dy = -reach; dy <= reach; dy++) { + for (int dz = -reach; dz <= reach; dz++) { + double dist = Math.sqrt((double) dx * dx + (double) dy * dy + (double) dz * dz); + if (dist <= radius) { + canvas.set(centerX + dx, centerY + dy, centerZ + dz, CrystalRole.SHARD); + } + } + } + } + } + } + + int tipX = (int) Math.round(ox + direction[0] * length); + int tipY = (int) Math.round(oy + direction[1] * length); + int tipZ = (int) Math.round(oz + direction[2] * length); + canvas.set(tipX, tipY, tipZ, CrystalRole.TIP); + } + + private static double azimuth(IrisCrystalDistribution distribution, int index, RNG rng) { + if (distribution == IrisCrystalDistribution.GOLDEN_ANGLE) { + return Math.toRadians(index * GOLDEN_ANGLE); + } + return rng.d(0.0, Math.PI * 2.0); + } + + private static double fanFraction(int count, int index, RNG rng) { + if (count <= 1) { + return rng.d(0.0, 1.0); + } + double base = index / (double) (count - 1); + return Math.max(0.0, Math.min(1.0, base * 0.7 + rng.d(0.0, 0.3))); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java new file mode 100644 index 000000000..f500c5843 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java @@ -0,0 +1,66 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisFormation; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +public final class FormationBlockResolver { + private FormationBlockResolver() { + } + + public static BlockData resolve(IrisFormation f, IrisData data, FormationCanvas.Role role, Vector3i raw) { + int x = raw.getBlockX(); + int y = raw.getBlockY(); + int z = raw.getBlockZ(); + RNG paletteRng = new RNG(f.getSeed()); + + if (role == FormationCanvas.Role.CAP && capDefined(f)) { + BlockData cap = IrisProceduralBlocks.resolve(f.getCapBlock(), f.getCapPalette(), data, x, y, z, paletteRng); + if (cap != null) { + return cap; + } + } + + if (strataDefined(f)) { + IrisMaterialPalette strata = f.getStrataPalette(); + int thickness = Math.max(1, f.getStrataThickness()); + int band = Math.floorDiv(y, thickness); + BlockData strataBlock = strata.get(new RNG(f.getSeed() + (band * 31L)), x, band, z, data); + if (strataBlock != null) { + return strataBlock.clone(); + } + } + + return IrisProceduralBlocks.resolve(f.getBlock(), f.getBlockPalette(), data, x, y, z, paletteRng); + } + + private static boolean capDefined(IrisFormation f) { + return IrisProceduralBlocks.paletteSet(f.getCapPalette()) || (f.getCapBlock() != null && !f.getCapBlock().isEmpty()); + } + + private static boolean strataDefined(IrisFormation f) { + return IrisProceduralBlocks.paletteSet(f.getStrataPalette()); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationCanvas.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationCanvas.java new file mode 100644 index 000000000..b7b842a36 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationCanvas.java @@ -0,0 +1,61 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.util.common.math.Vector3i; + +import java.util.HashMap; +import java.util.Map; + +public final class FormationCanvas { + public enum Role { + BODY, + CAP + } + + private final Map cells = new HashMap<>(); + + public void setBody(int x, int y, int z) { + Vector3i key = new Vector3i(x, y, z); + if (cells.get(key) == Role.CAP) { + return; + } + cells.put(key, Role.BODY); + } + + public void setCap(int x, int y, int z) { + cells.put(new Vector3i(x, y, z), Role.CAP); + } + + public void remove(int x, int y, int z) { + cells.remove(new Vector3i(x, y, z)); + } + + public boolean has(int x, int y, int z) { + return cells.containsKey(new Vector3i(x, y, z)); + } + + public Map getCells() { + return cells; + } + + public boolean isEmpty() { + return cells.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java new file mode 100644 index 000000000..94c84ca77 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java @@ -0,0 +1,71 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisFormation; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.HashMap; +import java.util.Map; + +public final class FormationGenerator { + private FormationGenerator() { + } + + public static IrisObject generate(IrisFormation f, int variantIndex, RNG rng, IrisData data) { + int lo = Math.min(f.getHeightMin(), f.getHeightMax()); + int hi = Math.max(f.getHeightMin(), f.getHeightMax()); + int height = Math.max(3, rng.i(lo, hi + 1)); + + int wLo = Math.min(f.getBaseWidthMin(), f.getBaseWidthMax()); + int wHi = Math.max(f.getBaseWidthMin(), f.getBaseWidthMax()); + double baseRadius = Math.max(1, rng.i(wLo, wHi + 1)); + + FormationCanvas canvas = new FormationCanvas(); + + switch (f.getForm()) { + case SPIRE -> FormationShapeBuilder.spire(canvas, f, height, baseRadius, rng); + case HOODOO -> FormationShapeBuilder.hoodoo(canvas, f, height, baseRadius, rng); + case ARCH -> FormationShapeBuilder.arch(canvas, f, height, baseRadius, rng); + case SEA_STACK -> FormationShapeBuilder.seaStack(canvas, f, height, baseRadius, rng); + case BOULDER -> FormationShapeBuilder.boulder(canvas, f, height, baseRadius, rng); + case BASALT_COLUMN -> FormationShapeBuilder.basaltColumns(canvas, f, height, baseRadius, rng); + } + + if (canvas.isEmpty()) { + return null; + } + + Map resolved = new HashMap<>(); + for (Map.Entry entry : canvas.getCells().entrySet()) { + BlockData bd = FormationBlockResolver.resolve(f, data, entry.getValue(), entry.getKey()); + if (bd == null) { + continue; + } + resolved.put(entry.getKey(), bd); + } + + return IrisProceduralBlocks.assemble(resolved); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationProfiles.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationProfiles.java new file mode 100644 index 000000000..fcab2f6b7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationProfiles.java @@ -0,0 +1,58 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.engine.object.IrisFormation; +import art.arcane.iris.engine.object.IrisFormationProfile; + +public final class FormationProfiles { + private FormationProfiles() { + } + + public static double radiusAt(IrisFormation f, double baseRadius, double topRadius, double t) { + double clamped = Math.max(0.0, Math.min(1.0, t)); + return switch (f.getProfile()) { + case CONSTANT -> baseRadius; + case LINEAR -> baseRadius + (topRadius - baseRadius) * clamped; + case TAPER -> baseRadius + (topRadius - baseRadius) * smoothstep(clamped); + case PARABOLIC -> parabolic(f, baseRadius, topRadius, clamped); + case BULGE -> baseRadius + (Math.max(baseRadius, topRadius) - baseRadius) * bell(clamped) + (topRadius - baseRadius) * clamped * 0.25; + }; + } + + private static double parabolic(IrisFormation f, double baseRadius, double topRadius, double t) { + double waist = Math.max(0.0, Math.min(1.0, f.getProfileWaist())); + double floor = Math.max(0.0, Math.min(1.0, f.getProfileWaistFloor())); + double denom = Math.pow(Math.max(waist, 1.0 - waist), 2); + if (denom == 0) { + denom = 1; + } + double envelope = baseRadius + (topRadius - baseRadius) * t; + double pinch = floor + (1.0 - floor) * (Math.pow(t - waist, 2) / denom); + return Math.max(0.5, envelope * pinch); + } + + private static double smoothstep(double t) { + return 3.0 * t * t - 2.0 * t * t * t; + } + + private static double bell(double t) { + return Math.exp(-Math.pow(t - 0.5, 2) / (2.0 * 0.22 * 0.22)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationShapeBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationShapeBuilder.java new file mode 100644 index 000000000..bc0936633 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationShapeBuilder.java @@ -0,0 +1,215 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.engine.object.IrisFormation; +import art.arcane.iris.engine.object.tree.TreeFunctions; +import art.arcane.volmlib.util.math.RNG; + +public final class FormationShapeBuilder { + private FormationShapeBuilder() { + } + + public static void spire(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, RNG rng) { + double topRadius = f.getTopWidth(); + column(canvas, f, height, baseRadius, topRadius, 0, 0, rng, true); + } + + public static void seaStack(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, RNG rng) { + double topRadius = Math.max(1.0, baseRadius * 0.55); + if (f.getTopWidth() > 0) { + topRadius = f.getTopWidth(); + } + column(canvas, f, height, baseRadius, topRadius, 0, 0, rng, true); + } + + public static void hoodoo(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, RNG rng) { + double topRadius = f.getTopWidth() > 0 ? f.getTopWidth() : Math.max(1.0, baseRadius * 0.7); + column(canvas, f, height, baseRadius, topRadius, 0, 0, rng, false); + + int capRadius = f.getHoodooCapRadius(); + if (capRadius <= 0) { + return; + } + int capHeight = Math.max(1, f.getHoodooCapHeight()); + double lean = Math.toRadians(f.getLean()); + double azimuth = Math.toRadians(f.getLeanAzimuth()); + double shear = Math.tan(lean) * height; + double topOffX = Math.cos(azimuth) * shear; + double topOffZ = Math.sin(azimuth) * shear; + double wideRadius = topRadius + capRadius; + + for (int cy = 0; cy < capHeight; cy++) { + int y = height + cy; + double shrink = capHeight <= 1 ? 0 : (cy / (double) (capHeight - 1)) * 0.5; + double r = wideRadius * (1.0 - shrink); + disc(canvas, f, (int) Math.round(topOffX), y, (int) Math.round(topOffZ), r, true, rng); + } + } + + public static void boulder(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, RNG rng) { + double rx = baseRadius; + double ry = Math.max(2.0, height * 0.5); + double rz = baseRadius * (0.8 + rng.d(0.0, 0.3)); + double roughness = Math.max(0.0, Math.min(1.0, f.getRoughness())); + long noiseSeed = f.getSeed() + 4201L; + + int maxR = (int) Math.ceil(Math.max(rx, Math.max(ry, rz))) + 2; + for (int x = -maxR; x <= maxR; x++) { + for (int y = 0; y <= (int) Math.ceil(ry * 2) + 1; y++) { + for (int z = -maxR; z <= maxR; z++) { + double nx = x / rx; + double ny = (y - ry) / ry; + double nz = z / rz; + double d = nx * nx + ny * ny + nz * nz; + double wobble = (TreeFunctions.valueNoise3D(x, y, z, noiseSeed) - 0.5) * roughness * 0.9; + if (d + wobble <= 1.0) { + canvas.setBody(x, y, z); + } + } + } + } + } + + public static void arch(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, RNG rng) { + int span = Math.max(2, f.getArchSpan()); + int thickness = Math.max(1, f.getArchThickness()); + int legHeight = Math.max(2, (int) Math.round(height * 0.55)); + int halfSpan = span / 2; + double legRadius = Math.max(1.0, thickness / 2.0 + baseRadius * 0.25); + long noiseSeed = f.getSeed() + 7777L; + double roughness = Math.max(0.0, Math.min(1.0, f.getRoughness())); + + for (int side = -1; side <= 1; side += 2) { + int legX = side * (halfSpan + (int) Math.ceil(legRadius)); + for (int y = 0; y < legHeight; y++) { + disc(canvas, f, legX, y, 0, legRadius, false, rng); + } + } + + int archTop = legHeight + (int) Math.round(span * 0.45); + int leftX = -(halfSpan + (int) Math.ceil(legRadius)); + int rightX = halfSpan + (int) Math.ceil(legRadius); + double archWidth = (rightX - leftX) / 2.0; + double centerX = (leftX + rightX) / 2.0; + double archHeight = archTop - legHeight; + int half = Math.max(1, thickness / 2); + + for (int x = leftX; x <= rightX; x++) { + double nx = (x - centerX) / archWidth; + if (nx < -1.0 || nx > 1.0) { + continue; + } + double curveY = legHeight + archHeight * Math.sqrt(Math.max(0.0, 1.0 - nx * nx)); + int yc = (int) Math.round(curveY); + for (int dy = -half; dy <= half; dy++) { + for (int z = -half; z <= half; z++) { + double wobble = (TreeFunctions.valueNoise3D(x, yc + dy, z, noiseSeed) - 0.5) * roughness; + if (z * z + dy * dy <= half * half + 0.5 + wobble) { + canvas.setBody(x, Math.max(0, yc + dy), z); + } + } + } + } + } + + public static void basaltColumns(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, RNG rng) { + int count = Math.max(2, f.getBasaltColumns()); + int colRadius = Math.max(1, f.getBasaltColumnRadius()); + double variance = Math.max(0.0, Math.min(1.0, f.getBasaltHeightVariance())); + int spread = (int) Math.ceil(baseRadius); + + for (int c = 0; c < count; c++) { + double angle = rng.d(0.0, Math.PI * 2.0); + double dist = rng.d(0.0, spread); + int ox = (int) Math.round(Math.cos(angle) * dist); + int oz = (int) Math.round(Math.sin(angle) * dist); + double hVar = 1.0 - variance + rng.d(0.0, variance * 2.0); + int colHeight = Math.max(3, (int) Math.round(height * hVar)); + + for (int y = 0; y < colHeight; y++) { + for (int x = -colRadius; x <= colRadius; x++) { + for (int z = -colRadius; z <= colRadius; z++) { + if (Math.abs(x) + Math.abs(z) > colRadius) { + continue; + } + canvas.setBody(ox + x, y, oz + z); + } + } + } + if (colRadius > 0) { + canvas.setCap(ox, colHeight - 1, oz); + } + } + } + + private static void column(FormationCanvas canvas, IrisFormation f, int height, double baseRadius, double topRadius, int extraBaseX, int extraBaseZ, RNG rng, boolean capTop) { + double lean = Math.toRadians(f.getLean()); + double azimuth = Math.toRadians(f.getLeanAzimuth()); + double roughness = Math.max(0.0, Math.min(1.0, f.getRoughness())); + double jitter = Math.max(0.0, Math.min(1.0, f.getJitter())); + long noiseSeed = f.getSeed() + 9001L; + + for (int y = 0; y < height; y++) { + double t = height <= 1 ? 1.0 : (y / (double) (height - 1)); + double radius = FormationProfiles.radiusAt(f, baseRadius, topRadius, t); + double shear = Math.tan(lean) * y; + int cx = extraBaseX + (int) Math.round(Math.cos(azimuth) * shear); + int cz = extraBaseZ + (int) Math.round(Math.sin(azimuth) * shear); + boolean cap = capTop && y >= height - 2; + ringDisc(canvas, f, cx, y, cz, radius, roughness, jitter, noiseSeed, cap, rng); + } + } + + private static void ringDisc(FormationCanvas canvas, IrisFormation f, int cx, int y, int cz, double radius, double roughness, double jitter, long noiseSeed, boolean cap, RNG rng) { + int r = (int) Math.ceil(radius) + 1; + for (int x = -r; x <= r; x++) { + for (int z = -r; z <= r; z++) { + double dist = Math.sqrt(x * x + z * z); + double perturb = (TreeFunctions.valueNoise3D(cx + x, y, cz + z, noiseSeed) - 0.5) * roughness * (radius + 1.0); + double effective = radius + perturb; + if (dist <= effective) { + if (jitter > 0 && dist > effective - 1.0 && rng.chance(jitter * 0.5)) { + continue; + } + if (cap) { + canvas.setCap(cx + x, y, cz + z); + } else { + canvas.setBody(cx + x, y, cz + z); + } + } + } + } + } + + private static void disc(FormationCanvas canvas, IrisFormation f, int cx, int y, int cz, double radius, boolean cap, RNG rng) { + int r = (int) Math.ceil(radius) + 1; + for (int x = -r; x <= r; x++) { + for (int z = -r; z <= r; z++) { + if (Math.sqrt(x * x + z * z) <= radius) { + if (cap) { + canvas.setCap(cx + x, y, cz + z); + } else { + canvas.setBody(cx + x, y, cz + z); + } + } + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCapBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCapBuilder.java new file mode 100644 index 000000000..70a4bedce --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCapBuilder.java @@ -0,0 +1,118 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.fungi; + +import art.arcane.iris.engine.object.IrisFungus; +import art.arcane.iris.engine.object.IrisFungusCapShape; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.object.tree.TreeFunctions; +import art.arcane.iris.util.common.math.Vector3i; + +import java.util.Map; + +public final class FungusCapBuilder { + private FungusCapBuilder() { + } + + public static void build(Map roles, IrisFungus fungus, int baseRadius, double cx, int baseY, double cz, long seed) { + int radius = (int) Math.round(baseRadius + Math.max(0.0, fungus.getCapOverhang())); + int thickness = Math.max(1, Math.min(3, fungus.getCapThickness())); + double squish = Math.max(0.0, Math.min(1.0, fungus.getCapSquish())); + double droop = Math.max(0.0, fungus.getCapDroop()); + IrisFungusCapShape shape = fungus.getCapShape() == null ? IrisFungusCapShape.DOME : fungus.getCapShape(); + int icx = (int) Math.round(cx); + int icz = (int) Math.round(cz); + + boolean hasGill = blockOrPaletteSet(fungus.getGillBlock(), fungus.getGillPalette()); + boolean hasSpot = blockOrPaletteSet(fungus.getSpotBlock(), fungus.getSpotPalette()); + + double apexRise = apexRise(shape, radius) * (1.0 - 0.6 * squish); + double droopReach = Math.tan(Math.toRadians(droop)) * radius; + + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + double dist = Math.sqrt(dx * dx + dz * dz); + if (dist > radius + 0.5) { + continue; + } + double normalized = dist / Math.max(1.0, radius); + double surfaceTop = baseY + capSurface(shape, normalized, apexRise); + surfaceTop -= droopReach * Math.pow(normalized, 3); + + int topYi = (int) Math.round(surfaceTop); + for (int layer = 0; layer < thickness; layer++) { + int y = topYi - layer; + if (y < baseY - thickness) { + continue; + } + Vector3i pos = new Vector3i(icx + dx, y, icz + dz); + FungusCellRole role = FungusCellRole.CAP; + + boolean underside = layer == thickness - 1; + boolean topFace = layer == 0; + + if (underside && hasGill && rollNoise(icx + dx, y, icz + dz, seed + 4111L) <= fungus.getGillChance()) { + role = FungusCellRole.GILL; + } else if (topFace && hasSpot && spotNoise(icx + dx, y, icz + dz, seed + 9311L) <= fungus.getSpotChance()) { + role = FungusCellRole.SPOT; + } + + roles.putIfAbsent(pos, role); + } + } + } + } + + private static double apexRise(IrisFungusCapShape shape, int radius) { + return switch (shape) { + case DOME -> radius * 0.85; + case CONICAL -> radius * 1.4; + case FUNNEL -> radius * 0.5; + case FLAT -> radius * 0.35; + case FLAT_WIDE -> radius * 0.2; + }; + } + + private static double capSurface(IrisFungusCapShape shape, double normalized, double apexRise) { + double n = Math.max(0.0, Math.min(1.0, normalized)); + return switch (shape) { + case DOME -> apexRise * Math.sqrt(Math.max(0.0, 1.0 - n * n)); + case CONICAL -> apexRise * (1.0 - n); + case FUNNEL -> apexRise * (n * n); + case FLAT -> apexRise * Math.max(0.0, 1.0 - Math.pow(n, 4)); + case FLAT_WIDE -> apexRise * Math.max(0.0, 1.0 - Math.pow(n, 6)); + }; + } + + private static double rollNoise(int x, int y, int z, long seed) { + return TreeFunctions.valueNoise3D(x, y, z, seed); + } + + private static double spotNoise(int x, int y, int z, long seed) { + return TreeFunctions.valueNoise3D(Math.floorDiv(x, 2), y, Math.floorDiv(z, 2), seed); + } + + static boolean blockOrPaletteSet(String block, IrisMaterialPalette palette) { + if (IrisProceduralBlocks.paletteSet(palette)) { + return true; + } + return block != null && !block.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCellRole.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCellRole.java new file mode 100644 index 000000000..9f1eb9421 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCellRole.java @@ -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 . + */ + +package art.arcane.iris.engine.object.fungi; + +enum FungusCellRole { + STEM, + CAP, + GILL, + SPOT +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java new file mode 100644 index 000000000..16da73058 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java @@ -0,0 +1,104 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.fungi; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisFungus; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.HashMap; +import java.util.Map; + +public final class FungusGenerator { + private FungusGenerator() { + } + + public static IrisObject generate(IrisFungus fungus, int height, RNG rng, IrisData data) { + Map roles = new HashMap<>(); + long baseSeed = rng.getSeed(); + + if (fungus.isShelf()) { + FungusShelfBuilder.build(roles, fungus, rng, baseSeed); + } else { + buildUpright(roles, fungus, height, rng, baseSeed); + } + + if (roles.isEmpty()) { + return null; + } + + RNG paletteRng = new RNG(fungus.getSeed()); + Map resolved = new HashMap<>(); + for (Map.Entry entry : roles.entrySet()) { + Vector3i pos = entry.getKey(); + BlockData bd = resolveRole(fungus, entry.getValue(), data, pos, paletteRng); + if (bd == null) { + continue; + } + resolved.put(pos, bd); + } + + return IrisProceduralBlocks.assemble(resolved); + } + + private static void buildUpright(Map roles, IrisFungus fungus, int height, RNG rng, long baseSeed) { + int stemHeight = Math.max(1, height); + Map stemCells = new HashMap<>(); + double[] top = FungusStemBuilder.build(stemCells, fungus, stemHeight, baseSeed); + for (Vector3i v : stemCells.keySet()) { + roles.put(v, FungusCellRole.STEM); + } + + int radius = pickRadius(fungus, rng); + double cx = top[0]; + double cz = top[2]; + int capBaseY = (int) Math.round(top[1]); + FungusCapBuilder.build(roles, fungus, radius, cx, capBaseY, cz, baseSeed + 5557L); + } + + private static int pickRadius(IrisFungus fungus, RNG rng) { + int lo = Math.min(fungus.getCapRadiusMin(), fungus.getCapRadiusMax()); + int hi = Math.max(fungus.getCapRadiusMin(), fungus.getCapRadiusMax()); + lo = Math.max(1, lo); + hi = Math.max(lo, hi); + return rng.i(lo, hi + 1); + } + + private static BlockData resolveRole(IrisFungus fungus, FungusCellRole role, IrisData data, Vector3i pos, RNG paletteRng) { + int x = pos.getBlockX(); + int y = pos.getBlockY(); + int z = pos.getBlockZ(); + return switch (role) { + case STEM -> IrisProceduralBlocks.resolve(fungus.getStem(), fungus.getStemPalette(), data, x, y, z, paletteRng); + case CAP -> IrisProceduralBlocks.resolve(fungus.getCap(), fungus.getCapPalette(), data, x, y, z, paletteRng); + case GILL -> { + BlockData gill = IrisProceduralBlocks.resolve(fungus.getGillBlock(), fungus.getGillPalette(), data, x, y, z, paletteRng); + yield gill != null ? gill : IrisProceduralBlocks.resolve(fungus.getCap(), fungus.getCapPalette(), data, x, y, z, paletteRng); + } + case SPOT -> { + BlockData spot = IrisProceduralBlocks.resolve(fungus.getSpotBlock(), fungus.getSpotPalette(), data, x, y, z, paletteRng); + yield spot != null ? spot : IrisProceduralBlocks.resolve(fungus.getCap(), fungus.getCapPalette(), data, x, y, z, paletteRng); + } + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusShelfBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusShelfBuilder.java new file mode 100644 index 000000000..4a90388fe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusShelfBuilder.java @@ -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 . + */ + +package art.arcane.iris.engine.object.fungi; + +import art.arcane.iris.engine.object.IrisFungus; +import art.arcane.iris.engine.object.tree.TreeFunctions; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; + +import java.util.Map; + +public final class FungusShelfBuilder { + private FungusShelfBuilder() { + } + + public static void build(Map roles, IrisFungus fungus, RNG rng, long seed) { + int radius = Math.max(1, fungus.getShelfRadius()); + double azimuth = rng.d(0.0, Math.PI * 2.0); + double fanX = Math.sin(azimuth); + double fanZ = Math.cos(azimuth); + int stubHeight = Math.max(0, Math.min(2, fungus.getStemWidth())); + + for (int y = 0; y < stubHeight; y++) { + roles.put(new Vector3i(0, y, 0), FungusCellRole.STEM); + } + + int fanY = stubHeight; + boolean hasGill = FungusCapBuilder.blockOrPaletteSet(fungus.getGillBlock(), fungus.getGillPalette()); + boolean hasSpot = FungusCapBuilder.blockOrPaletteSet(fungus.getSpotBlock(), fungus.getSpotPalette()); + + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + double dist = Math.sqrt(dx * dx + dz * dz); + if (dist > radius + 0.5) { + continue; + } + double forward = dx * fanX + dz * fanZ; + if (forward < -0.5) { + continue; + } + double normalized = dist / Math.max(1.0, radius); + int lift = (int) Math.round(radius * 0.25 * (1.0 - normalized)); + int y = fanY + lift; + + FungusCellRole role = FungusCellRole.CAP; + if (hasSpot && spotNoise(dx, y, dz, seed + 9311L) <= fungus.getSpotChance()) { + role = FungusCellRole.SPOT; + } + roles.putIfAbsent(new Vector3i(dx, y, dz), role); + + if (hasGill && rollNoise(dx, y - 1, dz, seed + 4111L) <= fungus.getGillChance()) { + roles.putIfAbsent(new Vector3i(dx, y - 1, dz), FungusCellRole.GILL); + } + } + } + } + + private static double rollNoise(int x, int y, int z, long seed) { + return TreeFunctions.valueNoise3D(x, y, z, seed); + } + + private static double spotNoise(int x, int y, int z, long seed) { + return TreeFunctions.valueNoise3D(Math.floorDiv(x, 2), y, Math.floorDiv(z, 2), seed); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java new file mode 100644 index 000000000..677a8b196 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java @@ -0,0 +1,95 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.fungi; + +import art.arcane.iris.engine.object.IrisFungus; +import art.arcane.iris.engine.object.tree.TreeFunctions; +import art.arcane.iris.util.common.math.Vector3i; +import org.bukkit.block.data.BlockData; + +import java.util.Map; + +public final class FungusStemBuilder { + private FungusStemBuilder() { + } + + public static double[] build(Map stemCells, IrisFungus fungus, int stemHeight, long seed) { + int width = Math.max(1, Math.min(3, fungus.getStemWidth())); + double maxLean = stemHeight * Math.tan(Math.toRadians(Math.max(0.0, fungus.getStemCurve()))); + double leanRad = Math.toRadians(fungus.getStemLeanAzimuth()); + double leanX = Math.sin(leanRad); + double leanZ = Math.cos(leanRad); + double waveAmp = Math.max(0.0, fungus.getStemWaveAmplitude()); + double wavePeriods = fungus.getStemWavePeriods() == 0 ? 1.0 : fungus.getStemWavePeriods(); + double waveAzimuth = Math.toRadians(fungus.getStemLeanAzimuth() + 90.0); + double waveX = Math.sin(waveAzimuth); + double waveZ = Math.cos(waveAzimuth); + + double topCx = 0.0; + double topCz = 0.0; + int topY = Math.max(0, stemHeight - 1); + + for (int y = 0; y < stemHeight; y++) { + double t = stemHeight <= 1 ? 0.0 : y / (double) (stemHeight - 1); + double lean = maxLean * (t * t); + double wave = waveAmp * Math.sin(2.0 * Math.PI * wavePeriods * t + TreeFunctions.valueNoise1D(seed, seed) * Math.PI * 2.0); + double cx = lean * leanX + wave * waveX; + double cz = lean * leanZ + wave * waveZ; + + for (int[] xz : squarePositions(cx, cz, width)) { + stemCells.put(new Vector3i(xz[0], y, xz[1]), null); + } + + topCx = cx; + topCz = cz; + } + + return new double[]{topCx, topY, topCz}; + } + + static int[][] squarePositions(double cx, double cz, int width) { + if (width % 2 == 1) { + int half = width / 2; + int icx = (int) Math.round(cx); + int icz = (int) Math.round(cz); + int[][] out = new int[width * width][2]; + int idx = 0; + for (int dx = -half; dx <= half; dx++) { + for (int dz = -half; dz <= half; dz++) { + out[idx][0] = icx + dx; + out[idx][1] = icz + dz; + idx++; + } + } + return out; + } + int ox = (int) Math.floor(cx) - width / 2 + 1; + int oz = (int) Math.floor(cz) - width / 2 + 1; + int[][] out = new int[width * width][2]; + int idx = 0; + for (int dx = 0; dx < width; dx++) { + for (int dz = 0; dz < width; dz++) { + out[idx][0] = ox + dx; + out[idx][1] = oz + dz; + idx++; + } + } + return out; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinBlockCanvas.java b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinBlockCanvas.java new file mode 100644 index 000000000..e416f1f0f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinBlockCanvas.java @@ -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 . + */ + +package art.arcane.iris.engine.object.ruin; + +import art.arcane.iris.util.common.math.Vector3i; +import org.bukkit.block.data.BlockData; + +import java.util.HashMap; +import java.util.Map; + +final class RuinBlockCanvas { + enum Role { + STRUCTURE, + ACCENT + } + + static final class Cell { + private Role role; + private boolean structural; + private BlockData accentData; + + Cell(Role role, boolean structural) { + this.role = role; + this.structural = structural; + } + + Role role() { + return role; + } + + boolean structural() { + return structural; + } + + BlockData accentData() { + return accentData; + } + + void promote(Role next, boolean structuralNext) { + this.role = next; + this.structural = this.structural || structuralNext; + } + + void makeAccent(BlockData data) { + this.role = Role.ACCENT; + this.structural = false; + this.accentData = data; + } + } + + private final Map cells = new HashMap<>(); + + Map cells() { + return cells; + } + + void set(int x, int y, int z, Role role, boolean structural) { + Vector3i key = new Vector3i(x, y, z); + Cell existing = cells.get(key); + if (existing == null) { + cells.put(key, new Cell(role, structural)); + } else { + existing.promote(role, structural); + } + } + + void accent(int x, int y, int z, BlockData data) { + if (data == null) { + return; + } + cells.put(new Vector3i(x, y, z), accentCell(data)); + } + + private static Cell accentCell(BlockData data) { + Cell cell = new Cell(Role.ACCENT, false); + cell.makeAccent(data); + return cell; + } + + boolean has(int x, int y, int z) { + return cells.containsKey(new Vector3i(x, y, z)); + } + + Cell get(int x, int y, int z) { + return cells.get(new Vector3i(x, y, z)); + } + + void remove(int x, int y, int z) { + cells.remove(new Vector3i(x, y, z)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinDecoratorApplier.java b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinDecoratorApplier.java new file mode 100644 index 000000000..ce41f64cc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinDecoratorApplier.java @@ -0,0 +1,122 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.ruin; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.object.IrisRuinDecorator; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class RuinDecoratorApplier { + private static final int[][] HORIZONTAL = {{1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1}}; + + private RuinDecoratorApplier() { + } + + static void apply(RuinBlockCanvas canvas, IrisRuinDecorator decorator, IrisData data, long seed, RNG rng) { + List candidates = switch (decorator.getTarget()) { + case TOP -> topCandidates(canvas); + case SURFACE -> surfaceCandidates(canvas); + case BASE_SCATTER -> baseScatterCandidates(canvas, Math.max(1, decorator.getScatterRadius())); + }; + + RNG paletteRng = new RNG(seed); + for (Vector3i v : candidates) { + if (!rng.chance(decorator.getChance())) { + continue; + } + BlockData bd = IrisProceduralBlocks.resolve(decorator.getBlock(), decorator.getPalette(), data, v.getBlockX(), v.getBlockY(), v.getBlockZ(), paletteRng); + if (bd != null) { + canvas.accent(v.getBlockX(), v.getBlockY(), v.getBlockZ(), bd); + } + } + } + + private static List topCandidates(RuinBlockCanvas canvas) { + Map highest = new HashMap<>(); + for (Vector3i v : canvas.cells().keySet()) { + long key = columnKey(v.getBlockX(), v.getBlockZ()); + Integer current = highest.get(key); + if (current == null || v.getBlockY() > current) { + highest.put(key, v.getBlockY()); + } + } + List out = new ArrayList<>(); + for (Map.Entry entry : highest.entrySet()) { + int x = (int) (entry.getKey() >> 32); + int z = (int) (entry.getKey() & 0xFFFFFFFFL); + int y = entry.getValue() + 1; + if (!canvas.has(x, y, z)) { + out.add(new Vector3i(x, y, z)); + } + } + return out; + } + + private static List surfaceCandidates(RuinBlockCanvas canvas) { + List out = new ArrayList<>(); + for (Vector3i v : new ArrayList<>(canvas.cells().keySet())) { + for (int[] dir : HORIZONTAL) { + int nx = v.getBlockX() + dir[0]; + int ny = v.getBlockY(); + int nz = v.getBlockZ() + dir[2]; + if (!canvas.has(nx, ny, nz)) { + out.add(new Vector3i(nx, ny, nz)); + } + } + } + return out; + } + + private static List baseScatterCandidates(RuinBlockCanvas canvas, int radius) { + int minX = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + int minY = Integer.MAX_VALUE; + for (Vector3i v : canvas.cells().keySet()) { + minX = Math.min(minX, v.getBlockX()); + minZ = Math.min(minZ, v.getBlockZ()); + maxX = Math.max(maxX, v.getBlockX()); + maxZ = Math.max(maxZ, v.getBlockZ()); + minY = Math.min(minY, v.getBlockY()); + } + + List out = new ArrayList<>(); + for (int x = minX - radius; x <= maxX + radius; x++) { + for (int z = minZ - radius; z <= maxZ + radius; z++) { + if (!canvas.has(x, minY, z) && !canvas.has(x, minY + 1, z)) { + out.add(new Vector3i(x, minY, z)); + } + } + } + return out; + } + + private static long columnKey(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinGenerator.java new file mode 100644 index 000000000..a8fb9555c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinGenerator.java @@ -0,0 +1,199 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.ruin; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.object.IrisRuin; +import art.arcane.iris.engine.object.tree.TreeFunctions; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class RuinGenerator { + private RuinGenerator() { + } + + public static IrisObject generate(IrisRuin ruin, int variantIndex, RNG rng, IrisData data) { + int count = Math.max(1, ruin.getVariants()); + int height = pick(ruin.getHeightMin(), ruin.getHeightMax(), variantIndex, count, rng); + int width = pick(ruin.getWidthMin(), ruin.getWidthMax(), variantIndex, count, rng); + int length = pick(ruin.getLengthMin(), ruin.getLengthMax(), variantIndex, count, rng); + + RuinBlockCanvas canvas = new RuinBlockCanvas(); + switch (ruin.getForm()) { + case PILLAR -> RuinShapes.pillar(canvas, Math.max(1, width), Math.max(2, height), rng); + case WALL -> RuinShapes.wall(canvas, Math.max(1, width), Math.max(2, length), Math.max(2, height), rng); + case ARCH -> RuinShapes.arch(canvas, Math.max(1, width), Math.max(3, length), Math.max(3, height), rng); + case FLOOR_SLAB -> RuinShapes.floorSlab(canvas, Math.max(3, width), Math.max(3, length), Math.max(1, Math.min(height, 2))); + case RUBBLE -> RuinShapes.rubble(canvas, Math.max(3, width), Math.max(3, length), Math.max(1, Math.min(height, 4)), rng); + } + + applyBurial(ruin, canvas, height); + applyErosion(ruin, canvas); + applyAccents(ruin, canvas, rng, data); + + return resolve(ruin, canvas, data); + } + + private static int pick(int lo, int hi, int variantIndex, int count, RNG rng) { + int min = Math.min(lo, hi); + int max = Math.max(lo, hi); + if (min == max || count <= 1) { + return rng.i(min, max + 1); + } + double step = (max - min) / (double) (count - 1); + double base = min + step * variantIndex; + double jitter = rng.d(-step * 0.3, step * 0.3); + return (int) Math.round(Math.max(min, Math.min(max, base + jitter))); + } + + private static void applyBurial(IrisRuin ruin, RuinBlockCanvas canvas, int height) { + double fraction = clamp01(ruin.getBuriedFraction()); + if (fraction <= 0.0) { + return; + } + int sink = (int) Math.round(height * fraction); + if (sink <= 0) { + return; + } + Map moved = new HashMap<>(); + for (Map.Entry entry : canvas.cells().entrySet()) { + Vector3i v = entry.getKey(); + moved.put(new Vector3i(v.getBlockX(), v.getBlockY() - sink, v.getBlockZ()), entry.getValue()); + } + canvas.cells().clear(); + canvas.cells().putAll(moved); + } + + private static void applyErosion(IrisRuin ruin, RuinBlockCanvas canvas) { + double erosion = clamp01(ruin.getErosion()); + if (erosion <= 0.0) { + return; + } + double scale = Math.max(0.0001, ruin.getErosionScale()); + long erosionSeed = ruin.getSeed() * 0x2545F4914F6CDD1DL + 17L; + int minY = Integer.MAX_VALUE; + for (Vector3i v : canvas.cells().keySet()) { + minY = Math.min(minY, v.getBlockY()); + } + + List doomed = new ArrayList<>(); + for (Map.Entry entry : canvas.cells().entrySet()) { + Vector3i v = entry.getKey(); + RuinBlockCanvas.Cell cell = entry.getValue(); + if (cell.structural() || v.getBlockY() <= minY) { + continue; + } + int sx = (int) Math.round(v.getBlockX() * scale); + int sy = (int) Math.round(v.getBlockY() * scale); + int sz = (int) Math.round(v.getBlockZ() * scale); + double n = TreeFunctions.valueNoise3D(sx, sy, sz, erosionSeed); + if (n < erosion) { + doomed.add(v); + } + } + for (Vector3i v : doomed) { + canvas.cells().remove(v); + } + } + + private static void applyAccents(IrisRuin ruin, RuinBlockCanvas canvas, RNG rng, IrisData data) { + if (ruin.getAccents() == null || ruin.getAccents().isEmpty()) { + return; + } + int decoratorIndex = 0; + for (art.arcane.iris.engine.object.IrisRuinDecorator decorator : ruin.getAccents()) { + RuinDecoratorApplier.apply(canvas, decorator, data, ruin.getSeed() + decoratorIndex * 911L, rng.nextParallelRNG(decoratorIndex + 1)); + decoratorIndex++; + } + } + + private static IrisObject resolve(IrisRuin ruin, RuinBlockCanvas canvas, IrisData data) { + if (canvas.cells().isEmpty()) { + return null; + } + double scale = Math.max(0.0001, ruin.getWeatheringScale()); + double mossiness = clamp01(ruin.getMossiness()); + long weatherSeed = ruin.getSeed() * 0x9E3779B97F4A7C15L + 101L; + RNG paletteRng = new RNG(ruin.getSeed()); + + int minY = Integer.MAX_VALUE; + int maxY = Integer.MIN_VALUE; + for (Vector3i v : canvas.cells().keySet()) { + minY = Math.min(minY, v.getBlockY()); + maxY = Math.max(maxY, v.getBlockY()); + } + int span = Math.max(1, maxY - minY); + + Map blocks = new HashMap<>(); + for (Map.Entry entry : canvas.cells().entrySet()) { + Vector3i v = entry.getKey(); + RuinBlockCanvas.Cell cell = entry.getValue(); + BlockData bd; + if (cell.role() == RuinBlockCanvas.Role.ACCENT) { + bd = cell.accentData(); + } else { + bd = structuralBlock(ruin, cell, v, data, paletteRng, scale, mossiness, weatherSeed, minY, span); + } + if (bd != null) { + blocks.put(v, bd); + } + } + + return IrisProceduralBlocks.assemble(blocks); + } + + private static BlockData structuralBlock(IrisRuin ruin, RuinBlockCanvas.Cell cell, Vector3i v, IrisData data, RNG paletteRng, double scale, double mossiness, long weatherSeed, int minY, int span) { + boolean weathered = false; + if (mossiness > 0.0) { + int sx = (int) Math.round(v.getBlockX() * scale); + int sy = (int) Math.round(v.getBlockY() * scale); + int sz = (int) Math.round(v.getBlockZ() * scale); + double n = TreeFunctions.valueNoise3D(sx, sy, sz, weatherSeed); + double normalized = (v.getBlockY() - minY) / (double) span; + double gradient = 1.0 - 0.6 * normalized; + weathered = n < mossiness * gradient; + } + + if (weathered) { + BlockData wd = IrisProceduralBlocks.resolve(ruin.getWeatheredBlock(), ruin.getWeatheringPalette(), data, v.getBlockX(), v.getBlockY(), v.getBlockZ(), paletteRng); + if (wd != null) { + return wd; + } + } + return IrisProceduralBlocks.resolve(ruin.getBlock(), ruin.getBlockPalette(), data, v.getBlockX(), v.getBlockY(), v.getBlockZ(), paletteRng); + } + + private static double clamp01(double value) { + if (value < 0.0) { + return 0.0; + } + if (value > 1.0) { + return 1.0; + } + return value; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinShapes.java b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinShapes.java new file mode 100644 index 000000000..db012067a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/ruin/RuinShapes.java @@ -0,0 +1,145 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.ruin; + +import art.arcane.volmlib.util.math.RNG; + +final class RuinShapes { + private RuinShapes() { + } + + static void pillar(RuinBlockCanvas canvas, int width, int height, RNG rng) { + int half = (width - 1) / 2; + int broken = Math.max(2, height - rng.i(0, Math.max(1, height / 3))); + for (int y = 0; y < broken; y++) { + boolean bottom = y == 0; + int jitter = y >= broken - 1 ? rng.i(0, 2) : 0; + for (int x = -half; x <= half; x++) { + for (int z = -half; z <= half; z++) { + if (y == broken - 1 && jitter > 0 && (Math.abs(x) == half || Math.abs(z) == half) && rng.chance(0.5)) { + continue; + } + canvas.set(x, y, z, RuinBlockCanvas.Role.STRUCTURE, bottom); + } + } + } + + int stub = rng.i(0, 3); + if (stub > 0) { + int dir = rng.i(0, 4); + int dx = dir == 0 ? 1 : (dir == 1 ? -1 : 0); + int dz = dir == 2 ? 1 : (dir == 3 ? -1 : 0); + for (int s = 1; s <= stub; s++) { + canvas.set((half + s) * dx, 0, (half + s) * dz, RuinBlockCanvas.Role.STRUCTURE, true); + } + } + } + + static void wall(RuinBlockCanvas canvas, int thickness, int length, int height, RNG rng) { + int half = (length - 1) / 2; + int tHalf = (thickness - 1) / 2; + long edgeSeed = rng.lmax(); + for (int z = -half; z <= half; z++) { + int top = height - jaggedDrop(z, height, edgeSeed); + for (int y = 0; y < top; y++) { + if (y > 0 && rng.chance(0.10)) { + continue; + } + for (int x = -tHalf; x <= thickness - 1 - tHalf; x++) { + canvas.set(x, y, z, RuinBlockCanvas.Role.STRUCTURE, y == 0); + } + } + } + } + + static void arch(RuinBlockCanvas canvas, int thickness, int span, int height, RNG rng) { + int legSpacing = Math.max(2, span); + int legGap = (legSpacing - 1) / 2; + int tHalf = (thickness - 1) / 2; + int legTop = Math.max(2, height - Math.max(2, (legSpacing / 2))); + + for (int side = -1; side <= 1; side += 2) { + int legZ = side * legGap; + for (int y = 0; y < legTop; y++) { + for (int x = -tHalf; x <= thickness - 1 - tHalf; x++) { + canvas.set(x, y, legZ, RuinBlockCanvas.Role.STRUCTURE, y <= 1); + } + } + } + + double radius = legGap; + int crown = legTop + (int) Math.round(radius); + for (int z = -legGap; z <= legGap; z++) { + double inside = radius * radius - (z * z); + if (inside < 0) { + continue; + } + int y = legTop + (int) Math.round(Math.sqrt(inside)); + for (int yy = legTop; yy <= y && yy <= crown; yy++) { + boolean keystone = Math.abs(z) <= 1; + for (int x = -tHalf; x <= thickness - 1 - tHalf; x++) { + canvas.set(x, yy, z, RuinBlockCanvas.Role.STRUCTURE, keystone); + } + } + } + } + + static void floorSlab(RuinBlockCanvas canvas, int width, int length, int thickness) { + int xHalf = (width - 1) / 2; + int zHalf = (length - 1) / 2; + for (int y = 0; y < thickness; y++) { + for (int x = -xHalf; x <= width - 1 - xHalf; x++) { + for (int z = -zHalf; z <= length - 1 - zHalf; z++) { + canvas.set(x, y, z, RuinBlockCanvas.Role.STRUCTURE, y == 0); + } + } + } + } + + static void rubble(RuinBlockCanvas canvas, int width, int length, int peak, RNG rng) { + int xHalf = (width - 1) / 2; + int zHalf = (length - 1) / 2; + long blobSeed = rng.lmax(); + double rx = Math.max(1.0, width / 2.0); + double rz = Math.max(1.0, length / 2.0); + + for (int x = -xHalf; x <= width - 1 - xHalf; x++) { + for (int z = -zHalf; z <= length - 1 - zHalf; z++) { + double radial = (x * x) / (rx * rx) + (z * z) / (rz * rz); + if (radial > 1.2) { + continue; + } + double falloff = Math.max(0.0, 1.0 - radial); + double noise = art.arcane.iris.engine.object.tree.TreeFunctions.valueNoise3D(x, 0, z, blobSeed); + int columnHeight = (int) Math.round(peak * falloff * (0.6 + 0.4 * noise)); + for (int y = 0; y <= columnHeight; y++) { + if (y > 0 && rng.chance(0.25)) { + continue; + } + canvas.set(x, y, z, RuinBlockCanvas.Role.STRUCTURE, y == 0 && radial < 0.5); + } + } + } + } + + private static int jaggedDrop(int z, int height, long seed) { + double n = art.arcane.iris.engine.object.tree.TreeFunctions.valueNoise1D(z * 0.9, seed); + return (int) Math.round(n * Math.max(1, height - 1) * 0.6); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectVacuumTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectVacuumTest.java new file mode 100644 index 000000000..52c057465 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectVacuumTest.java @@ -0,0 +1,129 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisObjectVacuumTest { + + @Test + public void footprintExtentsMatchObjectWidthForEvenAndOdd() { + int evenLow = IrisObjectVacuum.footprintLow(10); + int evenHigh = IrisObjectVacuum.footprintHigh(10); + assertEquals(-5, evenLow); + assertEquals(4, evenHigh); + assertEquals(10, evenHigh - evenLow + 1); + + int oddLow = IrisObjectVacuum.footprintLow(15); + int oddHigh = IrisObjectVacuum.footprintHigh(15); + assertEquals(-7, oddLow); + assertEquals(7, oddHigh); + assertEquals(15, oddHigh - oddLow + 1); + + assertEquals(0, IrisObjectVacuum.footprintLow(1)); + assertEquals(0, IrisObjectVacuum.footprintHigh(1)); + } + + @Test + public void evenObjectRaisesFlushWithNoOverhang() { + int low = IrisObjectVacuum.footprintLow(10); + int high = IrisObjectVacuum.footprintHigh(10); + int originalY = 100; + int meetY = 110; + + for (int dx = low; dx <= high; dx++) { + for (int dz = low; dz <= high; dz++) { + int target = IrisObjectVacuum.columnTargetY(dx, dz, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertEquals("column inside footprint must be flush at the object base", meetY, target); + } + } + + int justOutsideHigh = IrisObjectVacuum.columnTargetY(high + 1, 0, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertTrue("one block past the high edge must taper, not stay full", justOutsideHigh < meetY); + assertTrue(justOutsideHigh > originalY); + + int justOutsideLow = IrisObjectVacuum.columnTargetY(low - 1, 0, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertEquals("taper must be symmetric on both axes", justOutsideHigh, justOutsideLow); + } + + @Test + public void downwardBendCarvesWhenBaseBelowSurface() { + int low = IrisObjectVacuum.footprintLow(10); + int high = IrisObjectVacuum.footprintHigh(10); + int originalY = 120; + int meetY = 105; + + int center = IrisObjectVacuum.columnTargetY(0, 0, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertEquals("inside footprint must carve down to the object base", meetY, center); + assertTrue("downward bend must drop below the original surface", center < originalY); + + int far = IrisObjectVacuum.columnTargetY(high + 200, 0, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertEquals("beyond the radius the surface is untouched", originalY, far); + } + + @Test + public void columnsOutsideRadiusAreUnchanged() { + int low = -5; + int high = 4; + int originalY = 64; + int meetY = 80; + + int beyond = IrisObjectVacuum.columnTargetY(high + 13, 0, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertEquals(originalY, beyond); + + int atRadiusEdge = IrisObjectVacuum.columnTargetY(high + 12, 0, low, high, low, high, 12.0, 2.0, originalY, meetY); + assertEquals(originalY, atRadiusEdge); + } + + @Test + public void resolveRadiusMatchesModesAndHonorsSettingsOverride() { + assertEquals(12, IrisObjectVacuum.resolveRadius(ObjectPlaceMode.VACUUM, null)); + assertEquals(20, IrisObjectVacuum.resolveRadius(ObjectPlaceMode.VACUUM_HIGH, null)); + assertEquals(8, IrisObjectVacuum.resolveRadius(ObjectPlaceMode.VACUUM_FAST, null)); + assertEquals(12, IrisObjectVacuum.resolveRadius(ObjectPlaceMode.VACUUM_ORGANIC, null)); + + IrisVacuumSettings settings = new IrisVacuumSettings(); + settings.setRadius(30); + assertEquals(30, IrisObjectVacuum.resolveRadius(ObjectPlaceMode.VACUUM_FAST, settings)); + } + + @Test + public void resolveStepIsCoarseOnlyForFast() { + assertEquals(1, IrisObjectVacuum.resolveStep(ObjectPlaceMode.VACUUM)); + assertEquals(1, IrisObjectVacuum.resolveStep(ObjectPlaceMode.VACUUM_HIGH)); + assertEquals(2, IrisObjectVacuum.resolveStep(ObjectPlaceMode.VACUUM_FAST)); + assertEquals(1, IrisObjectVacuum.resolveStep(ObjectPlaceMode.VACUUM_ORGANIC)); + } + + @Test + public void isVacuumModeOnlyTrueForVacuumModes() { + assertTrue(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.VACUUM)); + assertTrue(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.VACUUM_HIGH)); + assertTrue(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.VACUUM_FAST)); + assertTrue(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.VACUUM_ORGANIC)); + assertFalse(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.CENTER_HEIGHT)); + assertFalse(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.STILT)); + assertFalse(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.PAINT)); + assertFalse(IrisObjectVacuum.isVacuumMode(ObjectPlaceMode.FLOATING)); + } +} diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java index e7877b98b..30eb85956 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java @@ -90,12 +90,9 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.*; import java.util.List; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; public class NMSBinding implements INMSBinding { private final KMap baseBiomeCache = new KMap<>(); @@ -453,11 +450,6 @@ public class NMSBinding implements INMSBinding { return keys; } - @Override - public boolean isBukkit() { - return true; - } - @Override public int getBiomeId(Biome biome) { for (World i : Bukkit.getWorlds()) { @@ -596,42 +588,6 @@ public class NMSBinding implements INMSBinding { }); } - @Override - public boolean purgeChunk(World world, int x, int z) { - ServerLevel level = ((CraftWorld) world).getHandle(); - try { - CountDownLatch latch = new CountDownLatch(1); - Runnable unload = () -> { - try { - if (world.isChunkLoaded(x, z)) { - world.unloadChunk(x, z, false); - } - } finally { - latch.countDown(); - } - }; - if (!J.runRegion(world, x, z, unload)) { - return false; - } - if (!latch.await(15, TimeUnit.SECONDS)) { - Iris.warn("Chunk purge timed out waiting to unload " + x + "," + z + " in " + world.getName() + "."); - return false; - } - - for (MoonriseRegionFileIO.RegionFileType type : MoonriseRegionFileIO.RegionFileType.values()) { - MoonriseRegionFileIO.scheduleSave(level, x, z, null, type); - } - MoonriseRegionFileIO.flush(level); - return true; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } catch (Throwable e) { - Iris.reportError(e); - return false; - } - } - public ItemStack applyCustomNbt(ItemStack itemStack, KMap customNbt) throws IllegalArgumentException { if (customNbt != null && !customNbt.isEmpty()) { net.minecraft.world.item.ItemStack s = CraftItemStack.asNMSCopy(itemStack); diff --git a/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java b/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java index f83c350df..7955d5b88 100644 --- a/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java +++ b/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java @@ -97,12 +97,9 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.*; import java.util.List; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; public class NMSBinding implements INMSBinding { private final KMap baseBiomeCache = new KMap<>(); @@ -583,11 +580,6 @@ public class NMSBinding implements INMSBinding { return true; } - @Override - public boolean isBukkit() { - return true; - } - @Override public int getBiomeId(Biome biome) { for (World i : Bukkit.getWorlds()) { @@ -726,42 +718,6 @@ public class NMSBinding implements INMSBinding { }); } - @Override - public boolean purgeChunk(World world, int x, int z) { - ServerLevel level = ((CraftWorld) world).getHandle(); - try { - CountDownLatch latch = new CountDownLatch(1); - Runnable unload = () -> { - try { - if (world.isChunkLoaded(x, z)) { - world.unloadChunk(x, z, false); - } - } finally { - latch.countDown(); - } - }; - if (!J.runRegion(world, x, z, unload)) { - return false; - } - if (!latch.await(15, TimeUnit.SECONDS)) { - Iris.warn("Chunk purge timed out waiting to unload " + x + "," + z + " in " + world.getName() + "."); - return false; - } - - for (MoonriseRegionFileIO.RegionFileType type : MoonriseRegionFileIO.RegionFileType.values()) { - MoonriseRegionFileIO.scheduleSave(level, x, z, null, type); - } - MoonriseRegionFileIO.flush(level); - return true; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } catch (Throwable e) { - Iris.reportError(e); - return false; - } - } - public ItemStack applyCustomNbt(ItemStack itemStack, KMap customNbt) throws IllegalArgumentException { if (customNbt != null && !customNbt.isEmpty()) { net.minecraft.world.item.ItemStack s = CraftItemStack.asNMSCopy(itemStack);