diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/core/src/main/java/art/arcane/iris/core/commands/CommandFind.java index 36335b505..f7bcbb88e 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -18,7 +18,9 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.Iris; import art.arcane.iris.core.ExternalDataPackPipeline; +import art.arcane.iris.core.service.ObjectStudioSaveService; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; @@ -101,6 +103,18 @@ public class CommandFind implements DirectorExecutor { return; } + Player studioPlayer = player(); + if (studioPlayer != null) { + try { + if (ObjectStudioSaveService.get().teleportTo(studioPlayer, object)) { + sender().sendMessage(C.GREEN + "Object Studio: teleporting to " + object); + return; + } + } catch (Throwable t) { + Iris.reportError(t); + } + } + if (e.hasObjectPlacement(object)) { e.gotoObject(object, player(), teleport); return; diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/core/src/main/java/art/arcane/iris/core/commands/CommandObject.java index 73129e923..5c67b1968 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -21,16 +21,19 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; import art.arcane.iris.core.link.WorldEditLink; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.service.ObjectSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.service.WandSVC; import art.arcane.iris.core.tools.IrisConverter; +import art.arcane.iris.core.tools.TreePlausibilizer; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.*; import art.arcane.volmlib.util.data.Cuboid; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.data.registry.Materials; import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.director.DirectorOrigin; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; @@ -222,6 +225,220 @@ public class CommandObject implements DirectorExecutor { } } + @Director(description = "Bridge unreachable leaves with hidden logs so trees are vanilla-decay-plausible", + origin = DirectorOrigin.BOTH, studio = false) + public void plausibilize( + @Param(description = "Pack key (trees/bonsai/smbase1), pack prefix (trees/), or filesystem path to a .iob file or directory") + String target, + @Param(description = "Analyze only, do not write", defaultValue = "false") + boolean dryRun, + @Param(description = "Flip persistent=true leaves to false and bridge them as well", + defaultValue = "false") + boolean normalize, + @Param(description = "Wipe scattered leaves and repaint a canopy shell around every log, then bridge any gaps with interior log tendrils", + defaultValue = "false") + boolean smoke, + @Param(description = "Canopy shell radius (smoke mode only), clamped to [0,5]", + defaultValue = "2") + int radius + ) { + List targets = resolveTargets(target); + if (targets.isEmpty()) { + sender().sendMessage(C.RED + "No objects matched: " + target); + return; + } + + sender().sendMessage(C.IRIS + "Plausibilize: queued " + targets.size() + " object(s)" + + (dryRun ? " [DRY RUN]" : "") + + (normalize ? " [NORMALIZE]" : "") + + (smoke ? " [SMOKE r=" + radius + "]" : "")); + + org.bukkit.command.CommandSender s = sender(); + J.a(() -> runPlausibilize(targets, dryRun, normalize, smoke, radius, s)); + } + + private List resolveTargets(String target) { + List out = new ArrayList<>(); + if (target == null || target.isEmpty()) { + return out; + } + + File direct = new File(target); + if (direct.isFile() && target.toLowerCase().endsWith(".iob")) { + out.add(new Target(direct.getName().replaceAll("\\.iob$", ""), direct)); + return out; + } + if (direct.isDirectory()) { + walkIob(direct, direct, out); + return out; + } + + IrisData irisData = data(); + if (irisData != null) { + ResourceLoader loader = irisData.getObjectLoader(); + if (!target.endsWith("/") && loader.findFile(target) != null) { + out.add(new Target(target, null)); + return out; + } + String prefix = target.endsWith("/") ? target : target + "/"; + for (String k : loader.getPossibleKeys()) { + if (k.startsWith(prefix)) { + out.add(new Target(k, null)); + } + } + return out; + } + + File packsFolder = Iris.instance.getDataFolder("packs"); + File[] packs = packsFolder.listFiles(File::isDirectory); + if (packs != null) { + for (File pack : packs) { + File objectsRoot = new File(pack, "objects"); + if (!objectsRoot.isDirectory()) continue; + File candidate = new File(objectsRoot, target + ".iob"); + if (candidate.isFile()) { + out.add(new Target(pack.getName() + "/" + target, candidate)); + continue; + } + File candidateDir = new File(objectsRoot, target); + if (candidateDir.isDirectory()) { + walkIob(candidateDir, objectsRoot, out); + } + } + } + return out; + } + + private static void walkIob(File root, File keyRoot, List out) { + File[] kids = root.listFiles(); + if (kids == null) return; + for (File f : kids) { + if (f.isDirectory()) { + walkIob(f, keyRoot, out); + } else if (f.getName().toLowerCase().endsWith(".iob")) { + String rel = keyRoot.toPath().relativize(f.toPath()).toString() + .replace(File.separatorChar, '/') + .replaceAll("\\.iob$", ""); + out.add(new Target(rel, f)); + } + } + } + + private record Target(String key, File file) { + } + + private static void runPlausibilize( + List targets, + boolean dryRun, + boolean normalize, + boolean smoke, + int radius, + org.bukkit.command.CommandSender s + ) { + int processed = 0; + int skipped = 0; + int failed = 0; + int changed = 0; + long totalLogsAdded = 0L; + long totalReachableBefore = 0L; + long totalLeaves = 0L; + long totalPersistentInput = 0L; + long totalLeavesAdded = 0L; + long totalLeavesRemoved = 0L; + long totalNormalized = 0L; + long totalUnreachableAfter = 0L; + + int progressStep = Math.max(1, targets.size() / 20); + int index = 0; + + for (Target t : targets) { + index++; + try { + IrisObject o = loadTarget(t); + if (o == null) { + s.sendMessage(C.YELLOW + " skip " + t.key() + ": failed to load"); + skipped++; + continue; + } + + TreePlausibilizer.Result r = dryRun + ? TreePlausibilizer.analyze(o, normalize, smoke, radius) + : TreePlausibilizer.apply(o, normalize, smoke, radius); + + if (r.skipReason() != null) { + s.sendMessage(C.YELLOW + " skip " + t.key() + ": " + r.skipReason()); + skipped++; + continue; + } + + boolean touched = r.logsAdded() > 0 || r.leavesAdded() > 0 + || r.leavesRemoved() > 0 || r.leavesNormalized() > 0; + if (!dryRun && touched) { + File dest = o.getLoadFile() != null ? o.getLoadFile() : t.file(); + if (dest != null) { + o.write(dest); + changed++; + } + } + + processed++; + totalLogsAdded += r.logsAdded(); + totalReachableBefore += r.reachableBefore(); + totalLeaves += r.totalLeaves(); + totalPersistentInput += r.persistentLeavesInput(); + totalLeavesAdded += r.leavesAdded(); + totalLeavesRemoved += r.leavesRemoved(); + totalNormalized += r.leavesNormalized(); + totalUnreachableAfter += r.unreachableAfter(); + + if (touched || targets.size() == 1) { + s.sendMessage(C.GRAY + " " + t.key() + + " leaves=" + r.totalLeaves() + + " persistentIn=" + r.persistentLeavesInput() + + " reachable=" + r.reachableBefore() + + " logsAdded=" + r.logsAdded() + + " leavesAdded=" + r.leavesAdded() + + " leavesRemoved=" + r.leavesRemoved() + + " normalized=" + r.leavesNormalized() + + " remaining=" + r.unreachableAfter()); + } + + if (targets.size() > 1 && index % progressStep == 0) { + s.sendMessage(C.IRIS + " [" + index + "/" + targets.size() + "]"); + } + } catch (Throwable e) { + s.sendMessage(C.RED + " fail " + t.key() + ": " + e.getClass().getSimpleName() + + ": " + e.getMessage()); + e.printStackTrace(); + failed++; + } + } + + s.sendMessage(C.IRIS + "Done." + + " processed=" + processed + + " changed=" + changed + + " skipped=" + skipped + + " failed=" + failed + + " leaves=" + totalLeaves + + " persistentIn=" + totalPersistentInput + + " reachableBefore=" + totalReachableBefore + + " logsAdded=" + totalLogsAdded + + " leavesAdded=" + totalLeavesAdded + + " leavesRemoved=" + totalLeavesRemoved + + " normalized=" + totalNormalized + + " remaining=" + totalUnreachableAfter); + } + + private static IrisObject loadTarget(Target t) throws IOException { + if (t.file() != null) { + IrisObject o = new IrisObject(); + o.read(t.file()); + o.setLoadFile(t.file()); + return o; + } + return IrisData.loadAnyObject(t.key(), null); + } + @Director(description = "Convert .schem files in the 'convert' folder to .iob files.") public void convert () { try { diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 3f938ac20..8d6639efa 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -24,6 +24,8 @@ import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.runtime.ObjectStudioActivation; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; @@ -123,6 +125,101 @@ public class CommandStudio implements DirectorExecutor { Iris.service(StudioSVC.class).open(sender(), seed, dimension.getLoadKey()); } + @Director(description = "Open an object studio world (grid of every object; dimension optional, defaults to all packs)", aliases = {"obj", "objs"}, sync = true) + public void object( + @Param(defaultValue = "null", description = "Optional dimension whose object pack to lay out; omit to aggregate objects from every pack", aliases = "dim", customHandler = NullableDimensionHandler.class) + IrisDimension dimension, + @Param(defaultValue = "1337", description = "The seed to generate the studio with", aliases = "s") + long seed + ) { + VolmitSender commandSender = sender(); + java.util.Map sources = new java.util.LinkedHashMap<>(); + IrisDimension hostDimension = dimension; + + if (dimension != null) { + IrisData data = dimension.getLoader(); + if (data == null) { + data = IrisData.get(dimension.getLoadFile().getParentFile().getParentFile()); + } + sources.put(data.getDataFolder().getName(), data); + } else { + File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder(); + File[] packs = workspace == null ? null : workspace.listFiles(); + if (packs != null) { + Arrays.sort(packs, java.util.Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File pack : packs) { + if (!pack.isDirectory()) continue; + File dimensionsDir = new File(pack, "dimensions"); + if (!dimensionsDir.isDirectory()) continue; + IrisData data = IrisData.get(pack); + String[] keys = data.getObjectLoader().getPossibleKeys(); + if (keys == null || keys.length == 0) continue; + sources.put(pack.getName(), data); + if (hostDimension == null) { + File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json")); + if (dimFiles != null && dimFiles.length > 0) { + String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", ""); + IrisDimension loaded = data.getDimensionLoader().load(loadKey); + if (loaded != null) { + hostDimension = loaded; + } + } + } + } + } + } + + if (hostDimension == null || sources.isEmpty()) { + commandSender.sendMessage(C.RED + "No packs with objects were found on this server."); + return; + } + + int totalObjects = 0; + for (IrisData d : sources.values()) { + String[] k = d.getObjectLoader().getPossibleKeys(); + if (k != null) totalObjects += k.length; + } + if (totalObjects == 0) { + commandSender.sendMessage(C.RED + "No objects to place across the selected pack(s)."); + return; + } + + hostDimension.setStudioMode(StudioMode.OBJECT_BUFFET); + ObjectStudioActivation.activate(hostDimension.getLoadKey()); + ObjectStudioActivation.setSources(hostDimension.getLoadKey(), sources); + + String scope = dimension == null + ? ("all packs [" + sources.size() + "]") + : ("\"" + hostDimension.getName() + "\""); + commandSender.sendMessage(C.GREEN + "Opening Object Studio for " + scope + " (" + + totalObjects + " objects)"); + + IrisDimension finalHost = hostDimension; + try { + Iris.service(StudioSVC.class).open(commandSender, seed, hostDimension.getLoadKey(), world -> { + if (world == null) return; + try { + WorldRuntimeControlService.get().applyObjectStudioWorldRules(world); + } catch (Throwable e) { + Iris.reportError("Failed to apply object studio world rules for " + world.getName(), e); + } + + if (commandSender.isPlayer()) { + Player p = commandSender.player(); + if (p != null) { + Location target = new Location(world, 0.5D, 66D, 0.5D); + J.runEntity(p, () -> { + PaperLib.teleportAsync(p, target).thenRun(() -> p.setGameMode(GameMode.CREATIVE)); + }); + } + } + }); + } catch (Throwable e) { + Iris.reportError("Failed to open object studio world \"" + finalHost.getLoadKey() + "\".", e); + commandSender.sendMessage(C.RED + "Failed to open object studio: " + e.getMessage()); + } + } + @Director(description = "Open VSCode for a dimension", aliases = {"vsc", "edit"}) public void vscode( @Param(defaultValue = "default", description = "The dimension to open VSCode for", aliases = "dim", customHandler = DimensionHandler.class) diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioActivation.java b/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioActivation.java new file mode 100644 index 000000000..c28475569 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioActivation.java @@ -0,0 +1,67 @@ +/* + * 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.core.loader.IrisData; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class ObjectStudioActivation { + private static final Set ACTIVE = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Map> SOURCES = new ConcurrentHashMap<>(); + + private ObjectStudioActivation() { + } + + public static void activate(String packKey) { + if (packKey == null) return; + ACTIVE.add(normalize(packKey)); + } + + public static void deactivate(String packKey) { + if (packKey == null) return; + String norm = normalize(packKey); + ACTIVE.remove(norm); + SOURCES.remove(norm); + } + + public static boolean isActive(String packKey) { + if (packKey == null) return false; + return ACTIVE.contains(normalize(packKey)); + } + + public static void setSources(String packKey, Map sources) { + if (packKey == null || sources == null || sources.isEmpty()) return; + SOURCES.put(normalize(packKey), new LinkedHashMap<>(sources)); + } + + public static Map getSources(String packKey) { + if (packKey == null) return null; + return SOURCES.get(normalize(packKey)); + } + + private static String normalize(String key) { + return key.toLowerCase(Locale.ROOT); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java b/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java new file mode 100644 index 000000000..789795722 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java @@ -0,0 +1,258 @@ +/* + * 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.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.bukkit.util.BlockVector; + +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class ObjectStudioLayout { + public static final int FLOOR_Y = 64; + private static final int DEFAULT_ROW_WIDTH_CAP = 160; + + private final int padding; + private final int rowWidthCap; + private final List cells; + private final Map byKey; + + private ObjectStudioLayout(int padding, int rowWidthCap, List cells) { + this.padding = padding; + this.rowWidthCap = rowWidthCap; + this.cells = Collections.unmodifiableList(cells); + Map index = new LinkedHashMap<>(); + for (GridCell cell : cells) { + index.putIfAbsent(cell.key(), cell); + index.putIfAbsent(cell.pack() + "/" + cell.key(), cell); + } + this.byKey = Collections.unmodifiableMap(index); + } + + public static ObjectStudioLayout build(IrisData data, int padding) { + Map sources = new LinkedHashMap<>(); + sources.put(data.getDataFolder().getName(), data); + return build(sources, padding, DEFAULT_ROW_WIDTH_CAP); + } + + public static ObjectStudioLayout build(Map sources, int padding) { + return build(sources, padding, DEFAULT_ROW_WIDTH_CAP); + } + + public static ObjectStudioLayout build(Map sources, int padding, int rowWidthCap) { + List entries = new ArrayList<>(); + for (Map.Entry entry : sources.entrySet()) { + String packName = entry.getKey(); + IrisData data = entry.getValue(); + String[] possible = data.getObjectLoader().getPossibleKeys(); + if (possible == null) continue; + List sorted = new ArrayList<>(Arrays.asList(possible)); + Collections.sort(sorted); + for (String key : sorted) { + entries.add(new PackEntry(packName, data, key)); + } + } + entries.sort((a, b) -> { + int c = a.pack.compareTo(b.pack); + if (c != 0) return c; + return a.key.compareTo(b.key); + }); + + List packed = new ArrayList<>(entries.size()); + int cursorX = 0; + int rowZ = 0; + int rowMaxDepth = 0; + + for (PackEntry entry : entries) { + File file = entry.data.getObjectLoader().findFile(entry.key); + if (file == null) { + continue; + } + BlockVector size; + try { + size = IrisObject.sampleSize(file); + } catch (Throwable e) { + Iris.reportError(e); + continue; + } + + int w = Math.max(1, size.getBlockX()); + int h = Math.max(1, size.getBlockY()); + int d = Math.max(1, size.getBlockZ()); + + int cellWidth = w + padding * 2; + int cellDepth = d + padding * 2; + + if (cursorX > 0 && cursorX + cellWidth > rowWidthCap) { + rowZ += rowMaxDepth; + cursorX = 0; + rowMaxDepth = 0; + } + + int originX = cursorX + padding; + int originZ = rowZ + padding; + int originY = FLOOR_Y + 1; + + packed.add(new GridCell(entry.pack, entry.key, originX, originY, originZ, w, h, d)); + + cursorX += cellWidth; + rowMaxDepth = Math.max(rowMaxDepth, cellDepth); + } + + return new ObjectStudioLayout(padding, rowWidthCap, packed); + } + + public static ObjectStudioLayout load(File file, Map sources, int padding) { + if (file == null || !file.exists()) { + return null; + } + try { + String raw = Files.readString(file.toPath()); + JSONObject root = new JSONObject(raw); + int storedPadding = root.optInt("padding", padding); + int storedCap = root.optInt("rowWidthCap", DEFAULT_ROW_WIDTH_CAP); + JSONArray arr = root.getJSONArray("cells"); + + List stored = new ArrayList<>(); + Set storedIds = new HashSet<>(); + for (int i = 0; i < arr.length(); i++) { + JSONObject c = arr.getJSONObject(i); + String pack = c.optString("pack", null); + if (pack == null || pack.isEmpty()) { + return null; + } + GridCell cell = new GridCell( + pack, + c.getString("key"), + c.getInt("x"), + c.getInt("y"), + c.getInt("z"), + c.getInt("w"), + c.getInt("h"), + c.getInt("d") + ); + stored.add(cell); + storedIds.add(cell.pack() + "/" + cell.key()); + } + + Set liveIds = new HashSet<>(); + for (Map.Entry entry : sources.entrySet()) { + String[] live = entry.getValue().getObjectLoader().getPossibleKeys(); + if (live == null) continue; + for (String k : live) { + liveIds.add(entry.getKey() + "/" + k); + } + } + + if (liveIds.size() == storedIds.size() && liveIds.containsAll(storedIds)) { + return new ObjectStudioLayout(storedPadding, storedCap, stored); + } + return null; + } catch (Throwable e) { + Iris.reportError(e); + return null; + } + } + + public void save(File file) { + if (file == null) { + return; + } + try { + if (file.getParentFile() != null) { + file.getParentFile().mkdirs(); + } + JSONObject root = new JSONObject(); + root.put("padding", padding); + root.put("rowWidthCap", rowWidthCap); + JSONArray arr = new JSONArray(); + for (GridCell cell : cells) { + JSONObject c = new JSONObject(); + c.put("pack", cell.pack()); + c.put("key", cell.key()); + c.put("x", cell.originX()); + c.put("y", cell.originY()); + c.put("z", cell.originZ()); + c.put("w", cell.w()); + c.put("h", cell.h()); + c.put("d", cell.d()); + arr.put(c); + } + root.put("cells", arr); + Files.writeString(file.toPath(), root.toString(2)); + } catch (Throwable e) { + Iris.reportError(e); + } + } + + public int padding() { + return padding; + } + + public List cells() { + return cells; + } + + public GridCell findAt(int worldX, int worldZ) { + for (GridCell cell : cells) { + if (worldX >= cell.originX() && worldX < cell.originX() + cell.w() + && worldZ >= cell.originZ() && worldZ < cell.originZ() + cell.d()) { + return cell; + } + } + return null; + } + + public GridCell get(String key) { + return byKey.get(key); + } + + private record PackEntry(String pack, IrisData data, String key) { + } + + public record GridCell(String pack, String key, int originX, int originY, int originZ, int w, int h, int d) { + public int chunkMinX() { + return originX >> 4; + } + + public int chunkMaxX() { + return (originX + w - 1) >> 4; + } + + public int chunkMinZ() { + return originZ >> 4; + } + + public int chunkMaxZ() { + return (originZ + d - 1) >> 4; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java index 4d9067519..1fe82a6b2 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -92,6 +92,35 @@ public final class WorldRuntimeControlService { return true; } + public boolean applyObjectStudioWorldRules(World world) { + if (world == null) { + return false; + } + + applyStudioWorldRules(world); + + setBooleanGameRule(world, false, "DO_FIRE_TICK", "doFireTick"); + setBooleanGameRule(world, false, "DO_MOB_SPAWNING", "doMobSpawning"); + setBooleanGameRule(world, false, "DO_MOB_LOOT", "doMobLoot"); + setBooleanGameRule(world, false, "DO_TRADER_SPAWNING", "doTraderSpawning"); + setBooleanGameRule(world, false, "DO_PATROL_SPAWNING", "doPatrolSpawning"); + setBooleanGameRule(world, false, "DO_INSOMNIA", "doInsomnia"); + setBooleanGameRule(world, true, "DO_IMMEDIATE_RESPAWN", "doImmediateRespawn"); + setBooleanGameRule(world, false, "FALL_DAMAGE", "fallDamage"); + setBooleanGameRule(world, false, "FIRE_DAMAGE", "fireDamage"); + setBooleanGameRule(world, false, "DROWNING_DAMAGE", "drowningDamage"); + setBooleanGameRule(world, false, "FREEZE_DAMAGE", "freezeDamage"); + setBooleanGameRule(world, false, "DO_WARDEN_SPAWNING", "doWardenSpawning"); + setBooleanGameRule(world, false, "MOB_GRIEFING", "mobGriefing"); + setBooleanGameRule(world, false, "DO_TILE_DROPS", "doTileDrops"); + setBooleanGameRule(world, true, "KEEP_INVENTORY", "keepInventory"); + setIntGameRule(world, 0, "RANDOM_TICK_SPEED", "randomTickSpeed"); + setIntGameRule(world, 0, "SPAWN_RADIUS", "spawnRadius"); + setIntGameRule(world, 0, "MAX_ENTITY_CRAMMING", "maxEntityCramming"); + applyNoonTimeLock(world); + return true; + } + public boolean applyNoonTimeLock(World world) { if (world == null) { return false; @@ -347,6 +376,74 @@ public final class WorldRuntimeControlService { } } + @SuppressWarnings("unchecked") + private static void setIntGameRule(World world, int value, String... names) { + GameRule gameRule = resolveIntGameRule(world, names); + if (gameRule != null) { + world.setGameRule(gameRule, value); + } + } + + @SuppressWarnings("unchecked") + private static GameRule resolveIntGameRule(World world, String... names) { + if (world == null || names == null || names.length == 0) { + return null; + } + + Set candidates = buildRuleNameCandidates(names); + for (String name : candidates) { + if (name == null || name.isBlank()) { + continue; + } + + try { + Field field = GameRule.class.getField(name); + Object value = field.get(null); + if (value instanceof GameRule gameRule && Integer.class.equals(gameRule.getType())) { + return (GameRule) gameRule; + } + } catch (Throwable ignored) { + } + + try { + GameRule byName = GameRule.getByName(name); + if (byName != null && Integer.class.equals(byName.getType())) { + return (GameRule) byName; + } + } catch (Throwable ignored) { + } + } + + String[] availableRules = world.getGameRules(); + if (availableRules == null || availableRules.length == 0) { + return null; + } + + Set normalizedCandidates = new LinkedHashSet<>(); + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) { + normalizedCandidates.add(normalizeRuleName(candidate)); + } + } + + for (String availableRule : availableRules) { + String normalizedAvailable = normalizeRuleName(availableRule); + if (!normalizedCandidates.contains(normalizedAvailable)) { + continue; + } + + try { + GameRule byName = GameRule.getByName(availableRule); + if (byName != null && Integer.class.equals(byName.getType())) { + return (GameRule) byName; + } + } catch (Throwable ignored) { + } + } + + return null; + } + @SuppressWarnings("unchecked") private static GameRule resolveBooleanGameRule(World world, String... names) { if (world == null || names == null || names.length == 0) { diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index 8ab03d677..b37c43c13 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -173,13 +173,19 @@ public class BoardSVC implements IrisService, BoardProvider { } private void tick() { - if (cancelled || !boardEnabled || !player.isOnline()) { + if (!boardEnabled || !player.isOnline()) { + return; + } + + if (cancelled) { + board.remove(); return; } if (!isEligibleWorld(player)) { boards.remove(player); - cancel(); + cancelled = true; + board.remove(); return; } @@ -189,8 +195,15 @@ public class BoardSVC implements IrisService, BoardProvider { } public void cancel() { + if (cancelled) { + return; + } cancelled = true; - J.runEntity(player, board::remove); + if (J.isOwnedByCurrentRegion(player) && player.isOnline()) { + board.remove(); + } else { + J.runEntity(player, board::remove); + } } public void update() { diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java new file mode 100644 index 000000000..ce5419c5f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java @@ -0,0 +1,347 @@ +/* + * 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.service; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.runtime.ObjectStudioActivation; +import art.arcane.iris.core.runtime.ObjectStudioLayout; +import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator; +import art.arcane.iris.util.common.plugin.IrisService; +import art.arcane.iris.util.common.scheduling.J; +import io.papermc.lib.PaperLib; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntitySpawnEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.WorldUnloadEvent; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +public class ObjectStudioSaveService implements IrisService { + public static final int INTERVAL_TICKS = 100; + private static final int CELLS_PER_PASS = 50; + + private static ObjectStudioSaveService INSTANCE; + + private final Map studios = new ConcurrentHashMap<>(); + private int taskId = -1; + + public static ObjectStudioSaveService get() { + ObjectStudioSaveService svc = INSTANCE; + if (svc != null) return svc; + svc = Iris.service(ObjectStudioSaveService.class); + return svc; + } + + @Override + public void onEnable() { + INSTANCE = this; + taskId = J.ar(this::pass, INTERVAL_TICKS); + } + + @Override + public void onDisable() { + if (taskId != -1) { + J.car(taskId); + taskId = -1; + } + studios.clear(); + INSTANCE = null; + } + + public void register(Engine engine, ObjectStudioGenerator generator) { + World world = engine.getTarget().getWorld().realWorld(); + if (world == null) return; + ObjectStudioLayout layout = generator.getLayout(); + if (layout == null) return; + + Map sources = generator.getPackData(); + if (sources == null || sources.isEmpty()) { + Iris.warn("Object Studio save disabled: no pack data sources available for world %s", world.getName()); + return; + } + + Map objectsDirs = new ConcurrentHashMap<>(); + for (Map.Entry e : sources.entrySet()) { + File dir = resolveObjectsDir(e.getValue()); + if (dir != null) { + objectsDirs.put(e.getKey(), dir); + } + } + if (objectsDirs.isEmpty()) { + Iris.warn("Object Studio save disabled: no resolvable objects folders for world %s", world.getName()); + return; + } + + ActiveStudio existing = studios.get(world.getUID()); + if (existing != null && existing.layout == layout) { + return; + } + + String packKey = engine.getDimension() == null ? null : engine.getDimension().getLoadKey(); + studios.put(world.getUID(), new ActiveStudio(world.getUID(), layout, objectsDirs, packKey)); + Iris.info("Object Studio live-save registered: world=%s cells=%d packs=%d", + world.getName(), layout.cells().size(), objectsDirs.size()); + } + + public void unregister(World world) { + if (world == null) return; + ActiveStudio removed = studios.remove(world.getUID()); + if (removed != null) { + if (removed.packKey != null) { + ObjectStudioActivation.deactivate(removed.packKey); + } + Iris.info("Object Studio live-save unregistered: world=%s", world.getName()); + } + } + + @EventHandler + public void onWorldUnload(WorldUnloadEvent event) { + unregister(event.getWorld()); + } + + @EventHandler(ignoreCancelled = true) + public void onCreatureSpawn(CreatureSpawnEvent event) { + if (!studios.containsKey(event.getLocation().getWorld().getUID())) return; + CreatureSpawnEvent.SpawnReason reason = event.getSpawnReason(); + if (reason == CreatureSpawnEvent.SpawnReason.CUSTOM + || reason == CreatureSpawnEvent.SpawnReason.COMMAND + || reason == CreatureSpawnEvent.SpawnReason.SPAWNER_EGG) { + return; + } + event.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onEntitySpawn(EntitySpawnEvent event) { + if (event instanceof CreatureSpawnEvent) return; + if (!studios.containsKey(event.getLocation().getWorld().getUID())) return; + if (event.getEntity() instanceof org.bukkit.entity.Player) return; + event.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.LEFT_CLICK_BLOCK) return; + Block clicked = event.getClickedBlock(); + if (clicked == null) return; + + World world = clicked.getWorld(); + ActiveStudio studio = studios.get(world.getUID()); + if (studio == null) return; + + GridCell cell = studio.layout.findAt(clicked.getX(), clicked.getZ()); + if (cell == null) return; + + Player player = event.getPlayer(); + Iris.info("Object Studio save triggered by %s for %s", player.getName(), cell.key()); + J.runRegion(world, cell.chunkMinX(), cell.chunkMinZ(), () -> { + try { + captureAndSave(studio, world, cell); + } catch (Throwable e) { + Iris.reportError(e); + } + }); + } + + public boolean teleportTo(Player player, String objectKey) { + if (player == null || objectKey == null) return false; + for (ActiveStudio studio : studios.values()) { + GridCell cell = studio.layout.get(objectKey); + if (cell == null) continue; + World world = Bukkit.getWorld(studio.worldId); + if (world == null) continue; + + double targetX = cell.originX() + cell.w() / 2.0D + 0.5D; + double targetZ = cell.originZ() + cell.d() / 2.0D + 0.5D; + double targetY = cell.originY() + cell.h() + 2.0D; + Location location = new Location(world, targetX, targetY, targetZ); + J.runEntity(player, () -> PaperLib.teleportAsync(player, location)); + Iris.info("Object Studio goto: %s -> %s at %.0f,%.0f,%.0f", + player.getName(), objectKey, location.getX(), location.getY(), location.getZ()); + return true; + } + return false; + } + + private static File resolveObjectsDir(IrisData data) { + File root = data.getDataFolder(); + if (root == null) return null; + File objects = new File(root, "objects"); + if (!objects.exists()) { + objects.mkdirs(); + } + return objects; + } + + private void pass() { + if (studios.isEmpty()) return; + + for (ActiveStudio studio : studios.values()) { + World world = Bukkit.getWorld(studio.worldId); + if (world == null) continue; + + int budget = CELLS_PER_PASS; + int size = studio.layout.cells().size(); + if (size == 0) continue; + + while (budget-- > 0) { + int idx = studio.cursor.getAndIncrement(); + if (idx >= size) { + studio.cursor.set(0); + idx = 0; + } + GridCell cell = studio.layout.cells().get(idx); + scheduleCapture(studio, world, cell); + if (size <= CELLS_PER_PASS && idx == size - 1) break; + } + } + } + + private void scheduleCapture(ActiveStudio studio, World world, GridCell cell) { + int chunkX = cell.chunkMinX(); + int chunkZ = cell.chunkMinZ(); + J.runRegion(world, chunkX, chunkZ, () -> { + try { + captureAndSave(studio, world, cell); + } catch (Throwable e) { + Iris.reportError(e); + } + }); + } + + private void captureAndSave(ActiveStudio studio, World world, GridCell cell) { + if (!allChunksLoaded(world, cell)) { + return; + } + + IrisObject snapshot = new IrisObject(cell.w(), cell.h(), cell.d()); + int originX = cell.originX(); + int originY = cell.originY(); + int originZ = cell.originZ(); + + boolean anyBlock = false; + for (int dx = 0; dx < cell.w(); dx++) { + for (int dy = 0; dy < cell.h(); dy++) { + for (int dz = 0; dz < cell.d(); dz++) { + Block block = world.getBlockAt(originX + dx, originY + dy, originZ + dz); + if (block.getType() == Material.AIR) continue; + snapshot.setUnsigned(dx, dy, dz, block, false); + anyBlock = true; + } + } + } + + String hashKey = cell.pack() + "/" + cell.key(); + long hash = hashOf(snapshot); + Long prior = studio.hashes.get(hashKey); + if (prior != null && prior == hash) { + return; + } + + if (!anyBlock && prior == null) { + studio.hashes.put(hashKey, hash); + return; + } + + studio.hashes.put(hashKey, hash); + + File targetFile = objectFileFor(studio, cell); + if (targetFile == null) return; + + J.a(() -> { + try { + File parent = targetFile.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + snapshot.write(targetFile); + Iris.info("Object Studio saved: %s/%s (%dx%dx%d)", + cell.pack(), cell.key(), cell.w(), cell.h(), cell.d()); + } catch (Throwable e) { + Iris.reportError(e); + } + }); + } + + private boolean allChunksLoaded(World world, GridCell cell) { + for (int cx = cell.chunkMinX(); cx <= cell.chunkMaxX(); cx++) { + for (int cz = cell.chunkMinZ(); cz <= cell.chunkMaxZ(); cz++) { + if (!world.isChunkLoaded(cx, cz)) { + return false; + } + } + } + return true; + } + + private static long hashOf(IrisObject snapshot) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + snapshot.write(baos); + byte[] bytes = baos.toByteArray(); + long h = 1125899906842597L; + for (byte b : bytes) { + h = 31 * h + b; + } + return h; + } catch (Throwable e) { + Iris.reportError(e); + return System.nanoTime(); + } + } + + private static File objectFileFor(ActiveStudio studio, GridCell cell) { + File objectsDir = studio.objectsDirs.get(cell.pack()); + if (objectsDir == null) return null; + String relative = cell.key().replace('\\', '/'); + return new File(objectsDir, relative + ".iob"); + } + + private static final class ActiveStudio { + final UUID worldId; + final ObjectStudioLayout layout; + final Map objectsDirs; + final String packKey; + final Map hashes = new ConcurrentHashMap<>(); + final AtomicInteger cursor = new AtomicInteger(); + + ActiveStudio(UUID worldId, ObjectStudioLayout layout, Map objectsDirs, String packKey) { + this.worldId = worldId; + this.layout = layout; + this.objectsDirs = objectsDirs; + this.packKey = packKey; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java b/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java new file mode 100644 index 000000000..8988f2912 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java @@ -0,0 +1,544 @@ +/* + * 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.tools; + +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.VectorMap; +import org.bukkit.Axis; +import org.bukkit.Tag; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Orientable; +import org.bukkit.block.data.type.Leaves; +import org.bukkit.util.BlockVector; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class TreePlausibilizer { + public static final int MAX_DISTANCE = 6; + public static final int DEFAULT_SHELL_RADIUS = 2; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1} + }; + private static final BlockData FALLBACK_LOG = B.get("minecraft:oak_log[axis=y]"); + private static final BlockData FALLBACK_LEAF = B.get("minecraft:oak_leaves[distance=1,persistent=false,waterlogged=false]"); + + private TreePlausibilizer() { + } + + public record Result( + int totalLeaves, + int persistentLeavesInput, + int reachableBefore, + int logsAdded, + int leavesAdded, + int leavesRemoved, + int leavesNormalized, + int unreachableAfter, + String skipReason + ) { + public static Result skipped(String reason) { + return new Result(0, 0, 0, 0, 0, 0, 0, 0, reason); + } + } + + public static Result analyze(IrisObject obj, boolean normalize, boolean smoke, int shellRadius) { + return run(obj, false, normalize, smoke, shellRadius); + } + + public static Result apply(IrisObject obj, boolean normalize, boolean smoke, int shellRadius) { + return run(obj, true, normalize, smoke, shellRadius); + } + + private static Result run(IrisObject obj, boolean mutate, boolean normalize, boolean smoke, int shellRadius) { + VectorMap blocks = obj.getBlocks(); + Map positions = new HashMap<>(blocks.size() * 2); + Set logPositions = new HashSet<>(); + Set originalLeafPositions = new HashSet<>(); + Set persistentLeafPositions = new HashSet<>(); + Map leafTypeCounts = new HashMap<>(); + + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE; + + for (Map.Entry entry : blocks) { + BlockVector pos = entry.getKey(); + BlockData data = entry.getValue(); + long key = packKey(pos); + positions.put(key, data); + int x = pos.getBlockX(); + int y = pos.getBlockY(); + int z = pos.getBlockZ(); + if (x < minX) minX = x; + if (y < minY) minY = y; + if (z < minZ) minZ = z; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + if (z > maxZ) maxZ = z; + if (isLog(data)) { + logPositions.add(key); + continue; + } + if (isLeaf(data)) { + boolean persistent = data instanceof Leaves leaves && leaves.isPersistent(); + if (persistent) { + persistentLeafPositions.add(key); + } + originalLeafPositions.add(key); + leafTypeCounts.merge(data, 1, Integer::sum); + } + } + + int persistentInput = persistentLeafPositions.size(); + int totalLeavesInitial = originalLeafPositions.size(); + + if (logPositions.isEmpty() && !originalLeafPositions.isEmpty()) { + return Result.skipped("leaves present but no logs to bridge from"); + } + if (logPositions.isEmpty() && !smoke) { + return new Result(0, 0, 0, 0, 0, 0, 0, 0, null); + } + + Set leafPositions; + int leavesRemoved = 0; + List removedLeafKeys = new ArrayList<>(); + + if (smoke) { + leafPositions = new HashSet<>(); + for (long key : originalLeafPositions) { + removedLeafKeys.add(key); + positions.remove(key); + } + leavesRemoved = removedLeafKeys.size(); + int r = Math.max(0, Math.min(shellRadius, 5)); + BlockData leafTemplate = pickDominantLeaf(leafTypeCounts); + paintShell( + logPositions, positions, leafPositions, + leafTemplate, r, + minX, minY, minZ, maxX, maxY, maxZ + ); + } else if (normalize) { + leafPositions = new HashSet<>(originalLeafPositions); + } else { + leafPositions = new HashSet<>(originalLeafPositions); + leafPositions.removeAll(persistentLeafPositions); + } + + int reachableBefore; + int logsAdded = 0; + Set unreached; + Map distances; + List inserts = new ArrayList<>(); + + if (!leafPositions.isEmpty() && !logPositions.isEmpty()) { + Set connectivityLeaves; + if (!normalize && !smoke) { + connectivityLeaves = new HashSet<>(leafPositions); + connectivityLeaves.addAll(persistentLeafPositions); + } else { + connectivityLeaves = leafPositions; + } + + distances = seedDistances(logPositions, connectivityLeaves); + reachableBefore = countReachable(leafPositions, distances); + unreached = new HashSet<>(leafPositions); + unreached.removeAll(distances.keySet()); + Set frontier = computeInitialFrontier(unreached, logPositions, distances); + + logsAdded = bridgeLoop( + unreached, frontier, distances, + logPositions, leafPositions, connectivityLeaves, positions, + inserts + ); + } else { + distances = new HashMap<>(); + unreached = new HashSet<>(); + reachableBefore = 0; + } + + int leavesAdded = 0; + List leafAdds = new ArrayList<>(); + if (smoke) { + BlockData leafTemplate = pickDominantLeaf(leafTypeCounts); + for (long key : leafPositions) { + leafAdds.add(new LeafAddition(key, leafTemplate)); + } + leavesAdded = leafAdds.size(); + } + + int leavesNormalized = 0; + List normalizeRewrites = new ArrayList<>(); + if (normalize && !smoke) { + for (long pos : leafPositions) { + BlockData data = positions.get(pos); + if (data instanceof Leaves leaves && leaves.isPersistent()) { + BlockData cloned = data.clone(); + ((Leaves) cloned).setPersistent(false); + normalizeRewrites.add(new LeafRewrite(pos, cloned)); + leavesNormalized++; + } + } + } + + if (mutate) { + if (smoke) { + for (long key : removedLeafKeys) { + if (!positions.containsKey(key)) { + blocks.remove(unpackKey(key)); + } + } + } + for (LeafAddition addition : leafAdds) { + blocks.put(unpackKey(addition.key()), addition.data()); + } + for (LogInsertion insertion : inserts) { + blocks.put(unpackKey(insertion.key()), insertion.data()); + } + for (LeafRewrite rewrite : normalizeRewrites) { + blocks.put(unpackKey(rewrite.key()), rewrite.data()); + } + } + + int finalLeafCount = leafPositions.size(); + if (!normalize && !smoke) { + finalLeafCount += persistentInput; + } + + return new Result( + smoke ? leavesAdded : totalLeavesInitial, + persistentInput, + reachableBefore, + logsAdded, + leavesAdded, + leavesRemoved, + leavesNormalized, + unreached.size(), + null + ); + } + + private static void paintShell( + Set logPositions, + Map positions, + Set leafPositions, + BlockData leafTemplate, + int radius, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ + ) { + if (radius <= 0) { + return; + } + int r2 = radius * radius; + int bxMin = minX; + int byMin = minY; + int bzMin = minZ; + int bxMax = maxX; + int byMax = maxY; + int bzMax = maxZ; + + for (long log : logPositions) { + int[] lx = unpack(log); + for (int dx = -radius; dx <= radius; dx++) { + int ax = lx[0] + dx; + if (ax < bxMin || ax > bxMax) continue; + int dx2 = dx * dx; + for (int dy = -radius; dy <= radius; dy++) { + int ay = lx[1] + dy; + if (ay < byMin || ay > byMax) continue; + int dy2 = dy * dy; + int partial = dx2 + dy2; + if (partial > r2) continue; + for (int dz = -radius; dz <= radius; dz++) { + if (partial + dz * dz > r2) continue; + int az = lx[2] + dz; + if (az < bzMin || az > bzMax) continue; + long nk = packXYZ(ax, ay, az); + if (logPositions.contains(nk)) continue; + if (positions.containsKey(nk)) continue; + positions.put(nk, leafTemplate); + leafPositions.add(nk); + } + } + } + } + } + + private static int bridgeLoop( + Set unreached, + Set frontier, + Map distances, + Set logPositions, + Set leafPositions, + Set connectivityLeaves, + Map positions, + List inserts + ) { + int logsAdded = 0; + int safetyLimit = unreached.size() + 32; + while (!unreached.isEmpty() && logsAdded < safetyLimit) { + long candidateKey = pickInteriorCandidate(frontier, unreached, connectivityLeaves); + BlockData logData = pickLogVariant(candidateKey, positions, logPositions); + inserts.add(new LogInsertion(candidateKey, logData)); + + logPositions.add(candidateKey); + leafPositions.remove(candidateKey); + connectivityLeaves.remove(candidateKey); + distances.remove(candidateKey); + unreached.remove(candidateKey); + frontier.remove(candidateKey); + positions.put(candidateKey, logData); + logsAdded++; + + int[] cx = unpack(candidateKey); + Deque q = new ArrayDeque<>(); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(cx[0] + n[0], cx[1] + n[1], cx[2] + n[2]); + if (connectivityLeaves.contains(nk)) { + Integer cur = distances.get(nk); + if (cur == null || cur > 1) { + if (cur == null) { + unreached.remove(nk); + frontier.remove(nk); + } + distances.put(nk, 1); + q.add(nk); + } + } + if (unreached.contains(nk)) { + frontier.add(nk); + } + } + while (!q.isEmpty()) { + long pos = q.poll(); + int d = distances.get(pos); + if (d >= MAX_DISTANCE) { + continue; + } + int[] px = unpack(pos); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(px[0] + n[0], px[1] + n[1], px[2] + n[2]); + if (connectivityLeaves.contains(nk)) { + Integer cur = distances.get(nk); + if (cur == null || cur > d + 1) { + if (cur == null) { + unreached.remove(nk); + frontier.remove(nk); + } + distances.put(nk, d + 1); + q.add(nk); + } + } + if (unreached.contains(nk)) { + frontier.add(nk); + } + } + } + } + return logsAdded; + } + + private static long pickInteriorCandidate( + Set frontier, Set unreached, Set connectivityLeaves + ) { + Set pool = !frontier.isEmpty() ? frontier : unreached; + long best = -1L; + int bestScore = -1; + for (long pos : pool) { + int[] xyz = unpack(pos); + int score = 0; + for (int[] n : NEIGHBORS) { + long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); + if (connectivityLeaves.contains(nk)) { + score++; + } + } + if (score > bestScore) { + bestScore = score; + best = pos; + if (score == 6) break; + } + } + return best; + } + + private static Set computeInitialFrontier( + Set unreached, Set logPositions, Map distances + ) { + Set frontier = new HashSet<>(); + for (long u : unreached) { + if (hasReachedNeighbor(u, distances, logPositions)) { + frontier.add(u); + } + } + return frontier; + } + + private static boolean hasReachedNeighbor(long key, Map distances, Set logPositions) { + int[] xyz = unpack(key); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); + if (logPositions.contains(nk) || distances.containsKey(nk)) { + return true; + } + } + return false; + } + + private static Map seedDistances(Set logPositions, Set leafPositions) { + Map dist = new HashMap<>(); + Deque queue = new ArrayDeque<>(); + + for (long leaf : leafPositions) { + int[] xyz = unpack(leaf); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); + if (logPositions.contains(nk)) { + dist.put(leaf, 1); + queue.add(leaf); + break; + } + } + } + + while (!queue.isEmpty()) { + long pos = queue.poll(); + int d = dist.get(pos); + if (d >= MAX_DISTANCE) { + continue; + } + int[] xyz = unpack(pos); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); + if (leafPositions.contains(nk) && !dist.containsKey(nk)) { + dist.put(nk, d + 1); + queue.add(nk); + } + } + } + + return dist; + } + + private static int countReachable(Set leafPositions, Map distances) { + int count = 0; + for (long leaf : leafPositions) { + if (distances.containsKey(leaf)) { + count++; + } + } + return count; + } + + private static BlockData pickDominantLeaf(Map leafTypeCounts) { + BlockData best = null; + int bestCount = -1; + for (Map.Entry e : leafTypeCounts.entrySet()) { + if (e.getValue() > bestCount) { + bestCount = e.getValue(); + best = e.getKey(); + } + } + if (best == null) { + return FALLBACK_LEAF.clone(); + } + BlockData clone = best.clone(); + if (clone instanceof Leaves leaves) { + leaves.setPersistent(false); + } + return clone; + } + + private static BlockData pickLogVariant(long target, Map positions, Set logPositions) { + if (logPositions.isEmpty()) { + return FALLBACK_LOG.clone(); + } + int[] tx = unpack(target); + long nearest = -1L; + long nearestDistSq = Long.MAX_VALUE; + for (long lp : logPositions) { + int[] lx = unpack(lp); + long dx = tx[0] - lx[0]; + long dy = tx[1] - lx[1]; + long dz = tx[2] - lx[2]; + long d2 = dx * dx + dy * dy + dz * dz; + if (d2 < nearestDistSq) { + nearestDistSq = d2; + nearest = lp; + } + } + BlockData source = positions.get(nearest); + if (source == null) { + return FALLBACK_LOG.clone(); + } + BlockData clone = source.clone(); + if (clone instanceof Orientable orientable) { + orientable.setAxis(Axis.Y); + } + return clone; + } + + private static boolean isLog(BlockData data) { + return Tag.LOGS.isTagged(data.getMaterial()); + } + + private static boolean isLeaf(BlockData data) { + return Tag.LEAVES.isTagged(data.getMaterial()) || data instanceof Leaves; + } + + private static long packKey(BlockVector pos) { + return packXYZ(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ()); + } + + private static long packXYZ(int x, int y, int z) { + long lx = (x + 32768L) & 0xFFFFL; + long ly = (y + 32768L) & 0xFFFFL; + long lz = (z + 32768L) & 0xFFFFL; + return (lx << 32) | (ly << 16) | lz; + } + + private static int[] unpack(long key) { + int x = (int) ((key >> 32) & 0xFFFFL) - 32768; + int y = (int) ((key >> 16) & 0xFFFFL) - 32768; + int z = (int) (key & 0xFFFFL) - 32768; + return new int[]{x, y, z}; + } + + private static BlockVector unpackKey(long key) { + int[] xyz = unpack(key); + return new BlockVector(xyz[0], xyz[1], xyz[2]); + } + + private record LogInsertion(long key, BlockData data) { + } + + private record LeafAddition(long key, BlockData data) { + } + + private record LeafRewrite(long key, BlockData data) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index e15e55952..435f25e49 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -25,6 +25,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.events.IrisEngineHotloadEvent; import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; @@ -106,6 +107,7 @@ public class IrisEngine implements Engine { private double maxBiomeLayerDensity; private double maxBiomeDecoratorDensity; private IrisComplex complex; + private UpperDimensionContext upperContext; private final AtomicBoolean modeFallbackLogged; public IrisEngine(EngineTarget target, boolean studio) { @@ -207,6 +209,7 @@ public class IrisEngine implements Engine { Iris.debug("Setup Engine " + getCacheID()); cacheId = RNG.r.nextInt(); complex = ensureComplex(); + upperContext = buildUpperContext(); effects = new IrisEngineEffects(this); hash32 = new CompletableFuture<>(); mantle.hotload(); @@ -234,6 +237,25 @@ public class IrisEngine implements Engine { Iris.debug("Engine Setup Complete " + getCacheID()); } + private UpperDimensionContext buildUpperContext() { + IrisDimension dim = getDimension(); + if (!dim.hasUpperDimension()) { + return null; + } + String upperKey = dim.getUpperDimension(); + IrisDimension upperDim = upperKey.equals(dim.getLoadKey()) + ? dim + : IrisData.loadAnyDimension(upperKey, getData()); + if (upperDim != null) { + UpperDimensionContext ctx = UpperDimensionContext.create(this, upperDim); + Iris.info("Upper dimension enabled: " + upperKey + + (ctx.isSelfReferencing() ? " (self-referencing)" : " (cross-referencing)")); + return ctx; + } + Iris.warn("Upper dimension '" + upperKey + "' could not be resolved, skipping upper terrain."); + return null; + } + private void setupMode() { EngineMode currentMode = mode; if (currentMode != null) { @@ -344,6 +366,7 @@ public class IrisEngine implements Engine { public void hotloadComplex() { complex.close(); complex = new IrisComplex(this); + upperContext = buildUpperContext(); } public void hotloadSilently() { diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java new file mode 100644 index 000000000..e426d3c45 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -0,0 +1,266 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.*; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.collection.KSet; +import art.arcane.iris.util.common.data.DataProvider; +import art.arcane.volmlib.util.math.M; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import org.bukkit.block.data.BlockData; + +import java.util.*; + +public class UpperDimensionContext implements DataProvider { + private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); + private final IrisDimension dimension; + private final IrisData data; + private final int chunkHeight; + private final ProceduralStream heightStream; + private final ProceduralStream biomeStream; + private final ProceduralStream rockStream; + private final boolean selfReferencing; + + private UpperDimensionContext(IrisDimension dimension, IrisData data, int chunkHeight, + ProceduralStream heightStream, + ProceduralStream biomeStream, + ProceduralStream rockStream, + boolean selfReferencing) { + this.dimension = dimension; + this.data = data; + this.chunkHeight = chunkHeight; + this.heightStream = heightStream; + this.biomeStream = biomeStream; + this.rockStream = rockStream; + this.selfReferencing = selfReferencing; + } + + public static UpperDimensionContext create(Engine engine, IrisDimension upperDim) { + boolean selfRef = upperDim.getLoadKey().equals(engine.getDimension().getLoadKey()); + int chunkHeight = engine.getHeight(); + if (selfRef) { + return createSelfReferencing(engine, chunkHeight); + } + return createCrossReferencing(engine, upperDim, chunkHeight); + } + + private static UpperDimensionContext createSelfReferencing(Engine engine, int chunkHeight) { + IrisComplex complex = engine.getComplex(); + return new UpperDimensionContext( + engine.getDimension(), + engine.getData(), + chunkHeight, + complex.getHeightStream(), + complex.getBaseBiomeStream(), + complex.getRockStream(), + true + ); + } + + private static UpperDimensionContext createCrossReferencing(Engine engine, IrisDimension upperDim, int chunkHeight) { + IrisData resolvedData = upperDim.getLoader(); + if (resolvedData == null) { + resolvedData = engine.getData(); + } + IrisData upperData = resolvedData; + long seedOffset = upperDim.getLoadKey().hashCode(); + RNG rng = new RNG(engine.getSeedManager().getComplex() ^ seedOffset); + double fluidHeight = upperDim.getFluidHeight(); + DataProvider dataProvider = () -> upperData; + + Map> generators = new HashMap<>(); + Set allBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); + upperDim.getRegions().forEach(regionKey -> { + IrisRegion region = upperData.getRegionLoader().load(regionKey); + if (region != null) { + region.getAllBiomes(dataProvider).forEach(biome -> { + allBiomes.add(biome); + biome.getGenerators().forEach(link -> { + IrisGenerator gen = link.getCachedGenerator(dataProvider); + if (gen != null) { + generators.computeIfAbsent(gen.getInterpolator(), k -> new HashSet<>()).add(gen); + } + }); + }); + } + }); + + Map> generatorBounds = new HashMap<>(); + for (Map.Entry> entry : generators.entrySet()) { + IdentityHashMap interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16)); + for (IrisBiome biome : allBiomes) { + double min = 0D; + double max = 0D; + for (IrisGenerator gen : entry.getValue()) { + String key = gen.getLoadKey(); + if (key == null || key.isBlank()) { + continue; + } + max += biome.getGenLinkMax(key, engine); + min += biome.getGenLinkMin(key, engine); + } + interpolatorBounds.put(biome, new NoiseBounds(min, max)); + } + generatorBounds.put(entry.getKey(), interpolatorBounds); + } + + ProceduralStream regionStyleStream = upperDim.getRegionStyle() + .create(rng.nextParallelRNG(883), upperData).stream() + .zoom(upperDim.getRegionZoom()); + ProceduralStream regionStream = regionStyleStream + .selectRarity(upperData.getRegionLoader().loadAll(upperDim.getRegions())); + + ProceduralStream landBiomeStream = regionStream + .convert(r -> upperDim.getLandBiomeStyle() + .create(rng.nextParallelRNG(InferredType.LAND.ordinal()), upperData).stream() + .zoom(upperDim.getBiomeZoom()) + .zoom(upperDim.getLandZoom()) + .zoom(r.getLandBiomeZoom()) + .selectRarity(upperData.getBiomeLoader().loadAll(r.getLandBiomes(), + t -> t.setInferredType(InferredType.LAND)))) + .convertAware2D(ProceduralStream::get); + ProceduralStream seaBiomeStream = regionStream + .convert(r -> upperDim.getSeaBiomeStyle() + .create(rng.nextParallelRNG(InferredType.SEA.ordinal()), upperData).stream() + .zoom(upperDim.getBiomeZoom()) + .zoom(upperDim.getSeaZoom()) + .zoom(r.getSeaBiomeZoom()) + .selectRarity(upperData.getBiomeLoader().loadAll(r.getSeaBiomes(), + t -> t.setInferredType(InferredType.SEA)))) + .convertAware2D(ProceduralStream::get); + ProceduralStream shoreBiomeStream = regionStream + .convert(r -> upperDim.getShoreBiomeStyle() + .create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), upperData).stream() + .zoom(upperDim.getBiomeZoom()) + .zoom(r.getShoreBiomeZoom()) + .selectRarity(upperData.getBiomeLoader().loadAll(r.getShoreBiomes(), + t -> t.setInferredType(InferredType.SHORE)))) + .convertAware2D(ProceduralStream::get); + + Map> inferredStreams = new HashMap<>(); + inferredStreams.put(InferredType.LAND, landBiomeStream); + inferredStreams.put(InferredType.SEA, seaBiomeStream); + inferredStreams.put(InferredType.SHORE, shoreBiomeStream); + + ProceduralStream bridgeStream = upperDim.getContinentalStyle() + .create(rng.nextParallelRNG(234234565), upperData) + .bake().scale(1D / upperDim.getContinentZoom()).bake().stream() + .convert(v -> v >= upperDim.getLandChance() ? InferredType.SEA : InferredType.LAND); + + ProceduralStream baseBiomeStream = bridgeStream + .convertAware2D((t, x, z) -> { + ProceduralStream stream = inferredStreams.get(t); + return stream != null ? stream.get(x, z) : inferredStreams.get(InferredType.LAND).get(x, z); + }); + + KList overlayNoise = upperDim.getOverlayNoise(); + ProceduralStream overlayStream = overlayNoise.isEmpty() + ? ProceduralStream.ofDouble((x, z) -> 0.0D) + : ProceduralStream.ofDouble((x, z) -> { + double value = 0D; + for (IrisShapedGeneratorStyle style : overlayNoise) { + value += style.get(rng, upperData, x, z); + } + return value; + }); + + long heightSeed = engine.getSeedManager().getHeight() ^ seedOffset; + + ProceduralStream heightStream = ProceduralStream.of((x, z) -> { + IrisBiome b = baseBiomeStream.get(x, z); + if (b == null) { + return fluidHeight; + } + double interpolatedHeight = 0; + for (Map.Entry> entry : generators.entrySet()) { + IrisInterpolator interpolator = entry.getKey(); + Set gens = entry.getValue(); + if (gens.isEmpty()) { + continue; + } + IdentityHashMap cachedBounds = generatorBounds.get(interpolator); + NoiseBounds sampledBounds = interpolator.interpolateBounds(x, z, (xx, zz) -> { + try { + IrisBiome bx = baseBiomeStream.get(xx, zz); + if (bx == null) { + return ZERO_NOISE_BOUNDS; + } + NoiseBounds bounds = cachedBounds != null ? cachedBounds.get(bx) : null; + if (bounds != null) { + return bounds; + } + double bMin = 0D; + double bMax = 0D; + for (IrisGenerator gen : gens) { + String key = gen.getLoadKey(); + if (key == null || key.isBlank()) { + continue; + } + bMax += bx.getGenLinkMax(key, engine); + bMin += bx.getGenLinkMin(key, engine); + } + return new NoiseBounds(bMin, bMax); + } catch (Throwable e) { + Iris.reportError(e); + return ZERO_NOISE_BOUNDS; + } + }); + double hi = sampledBounds.max(); + double lo = sampledBounds.min(); + double d = 0; + for (IrisGenerator gen : gens) { + d += M.lerp(lo, hi, gen.getHeight(x, z, heightSeed + 239945)); + } + interpolatedHeight += d / gens.size(); + } + return Math.max(Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), 0); + }, Interpolated.DOUBLE); + + ProceduralStream rockStream = upperDim.getRockPalette() + .getLayerGenerator(rng.nextParallelRNG(45), upperData).stream() + .select(upperDim.getRockPalette().getBlockData(upperData)); + + return new UpperDimensionContext( + upperDim, + upperData, + chunkHeight, + heightStream, + baseBiomeStream, + rockStream, + false + ); + } + + public int getUpperSurfaceY(int x, int z) { + double rawHeight = heightStream.get((double) x, (double) z); + return chunkHeight - 1 - (int) Math.round(rawHeight); + } + + public IrisBiome getUpperBiome(int x, int z) { + return biomeStream.get((double) x, (double) z); + } + + public BlockData getRockBlock(int x, int z) { + return rockStream.get((double) x, (double) z); + } + + public IrisDimension getDimension() { + return dimension; + } + + @Override + public IrisData getData() { + return data; + } + + public boolean isSelfReferencing() { + return selfReferencing; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java index 80372bc98..9c11c6d55 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java @@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedActuator; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; @@ -167,6 +168,39 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator } } } + + UpperDimensionContext upperContext = getEngine().getUpperContext(); + if (upperContext != null) { + int chunkHeight = h.getHeight(); + boolean bedrockEnabled = getDimension().isBedrock(); + int rawUpperSurface = upperContext.getUpperSurfaceY(realX, realZ); + int upperGap = getDimension().getUpperDimensionGap(); + int upperSurfaceY = Math.max(rawUpperSurface, he + upperGap); + + if (upperSurfaceY < chunkHeight - 1) { + IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ); + BlockData upperRock = upperContext.getRockBlock(realX, realZ); + int upperThickness = chunkHeight - 1 - upperSurfaceY; + KList upperBlocks = upperBiome != null + ? upperBiome.generateLayers(upperContext.getDimension(), + realX, realZ, rng, upperThickness, upperThickness, + upperContext.getData(), getComplex()) + : null; + + for (int y = chunkHeight - 1; y >= upperSurfaceY; y--) { + if (y == chunkHeight - 1 && bedrockEnabled) { + h.setRaw(xf, y, zf, BEDROCK); + continue; + } + int depthFromFace = y - upperSurfaceY; + if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) { + h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace)); + } else { + h.setRaw(xf, y, zf, upperRock); + } + } + } + } } } @@ -186,6 +220,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator ChunkedDataCache fluidCache = context.getFluid(); ChunkedDataCache rockCache = context.getRock(); int realX = xf + x; + UpperDimensionContext upperContext = getEngine().getUpperContext(); for (int zf = 0; zf < chunkDepth; zf++) { int realZ = zf + z; @@ -261,6 +296,36 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator } } } + + if (upperContext != null) { + int rawUpperSurface = upperContext.getUpperSurfaceY(realX, realZ); + int upperGap = dimension.getUpperDimensionGap(); + int upperSurfaceY = Math.max(rawUpperSurface, he + upperGap); + + if (upperSurfaceY < chunkHeight - 1) { + IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ); + BlockData upperRock = upperContext.getRockBlock(realX, realZ); + int upperThickness = chunkHeight - 1 - upperSurfaceY; + KList upperBlocks = upperBiome != null + ? upperBiome.generateLayers(upperContext.getDimension(), + realX, realZ, localRng, upperThickness, upperThickness, + upperContext.getData(), complex) + : null; + + for (int y = chunkHeight - 1; y >= upperSurfaceY; y--) { + if (y == chunkHeight - 1 && bedrockEnabled) { + h.setRaw(xf, y, zf, BEDROCK); + continue; + } + int depthFromFace = y - upperSurfaceY; + if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) { + h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace)); + } else { + h.setRaw(xf, y, zf, upperRock); + } + } + } + } } } } 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 2f3c637d0..7fbbab73a 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 @@ -32,6 +32,7 @@ import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.mantle.EngineMantle; @@ -95,6 +96,10 @@ import java.util.stream.Collectors; public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdater, Renderer, Hotloadable { IrisComplex getComplex(); + default @Nullable UpperDimensionContext getUpperContext() { + return null; + } + EngineMode getMode(); int getBlockUpdatesPerSecond(); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 2fe193c41..ed54db7af 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; @@ -191,9 +192,34 @@ public interface EngineMantle extends MatterGenerator { return; } - var chunk = getMantle().getChunk(x, z).use(); + UpperDimensionContext upperCtx = getEngine().getUpperContext(); + boolean protectUpper = t == BlockData.class && upperCtx != null; + + MantleChunk chunk = getMantle().getChunk(x, z).use(); try { - chunk.iterate(t, blocks::set); + if (protectUpper) { + int chunkBlockX = x << 4; + int chunkBlockZ = z << 4; + int gap = getEngine().getDimension().getUpperDimensionGap(); + int[] upperYs = new int[256]; + for (int i = 0; i < 256; i++) { + int lx = i >> 4; + int lz = i & 15; + int worldX = chunkBlockX + lx; + int worldZ = chunkBlockZ + lz; + int he = (int) Math.round(getEngine().getComplex().getHeightStream().get((double) worldX, (double) worldZ)); + int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ); + upperYs[i] = Math.max(rawUpper, he + gap); + } + chunk.iterate(t, (lx, y, lz, value) -> { + int colIdx = (lx << 4) | (lz & 15); + if (y < upperYs[colIdx]) { + blocks.set(lx, y, lz, value); + } + }); + } else { + chunk.iterate(t, blocks::set); + } } finally { chunk.release(); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index 2909bc15f..11413ad17 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -150,6 +150,20 @@ public class IrisCaveCarver3D { double thresholdPenalty, IrisRange worldYRange, int[] precomputedSurfaceHeights + ) { + return carve(writer, chunkX, chunkZ, columnWeights, minWeight, thresholdPenalty, worldYRange, precomputedSurfaceHeights, null); + } + + public int carve( + MantleWriter writer, + int chunkX, + int chunkZ, + double[] columnWeights, + double minWeight, + double thresholdPenalty, + IrisRange worldYRange, + int[] precomputedSurfaceHeights, + IrisRange overrideVerticalRange ) { PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start(); try { @@ -165,8 +179,9 @@ public class IrisCaveCarver3D { double resolvedMinWeight = Math.max(0D, Math.min(1D, minWeight)); double resolvedThresholdPenalty = Math.max(0D, thresholdPenalty); int worldHeight = writer.getMantle().getWorldHeight(); - int minY = Math.max(0, (int) Math.floor(profile.getVerticalRange().getMin())); - int maxY = Math.min(worldHeight - 1, (int) Math.ceil(profile.getVerticalRange().getMax())); + IrisRange effectiveVerticalRange = overrideVerticalRange != null ? overrideVerticalRange : profile.getVerticalRange(); + int minY = Math.max(0, (int) Math.floor(effectiveVerticalRange.getMin())); + int maxY = Math.min(worldHeight - 1, (int) Math.ceil(effectiveVerticalRange.getMax())); if (worldYRange != null) { int worldMinHeight = engine.getWorld().minHeight(); int rangeMinY = (int) Math.floor(worldYRange.getMin() - worldMinHeight); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index 963abde14..5804242b9 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; @@ -91,6 +92,11 @@ public class MantleCarvingComponent extends IrisMantleComponent { for (WeightedProfile weightedProfile : weightedProfiles) { carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights); } + + UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); + if (upperCtx != null && getDimension().isUpperDimensionCarving()) { + carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights); + } } @ChunkCoordinates @@ -99,6 +105,58 @@ public class MantleCarvingComponent extends IrisMantleComponent { carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, weightedProfile.worldYRange, chunkSurfaceHeights); } + private void carveUpperTerrain(UpperDimensionContext upperCtx, List normalProfiles, MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights) { + int chunkHeight = getEngineMantle().getEngine().getHeight(); + int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight(); + int gap = getDimension().getUpperDimensionGap(); + int baseX = PowerOfTwoCoordinates.chunkToBlock(cx); + int baseZ = PowerOfTwoCoordinates.chunkToBlock(cz); + + int minUpperSurfaceY = chunkHeight; + for (int localX = 0; localX < CHUNK_SIZE; localX++) { + int worldX = baseX + localX; + for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { + int worldZ = baseZ + localZ; + int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); + int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ); + int upperY = Math.max(rawUpper, lowerSurfaceHeights[columnIndex] + gap); + if (upperY < minUpperSurfaceY) { + minUpperSurfaceY = upperY; + } + } + } + + if (minUpperSurfaceY >= chunkHeight - 2) { + return; + } + + IrisRange upperYRange = new IrisRange( + minUpperSurfaceY + worldMinHeight, + chunkHeight - 1 + worldMinHeight + ); + IrisRange fullVerticalRange = new IrisRange(0, chunkHeight); + + int[] ceilingSurfaceHeights = new int[CHUNK_AREA]; + Arrays.fill(ceilingSurfaceHeights, chunkHeight - 1); + + for (WeightedProfile weightedProfile : normalProfiles) { + IrisRange constrainedRange; + if (weightedProfile.worldYRange != null) { + double min = Math.max(weightedProfile.worldYRange.getMin(), upperYRange.getMin()); + double max = Math.min(weightedProfile.worldYRange.getMax(), upperYRange.getMax()); + if (min >= max) { + continue; + } + constrainedRange = new IrisRange(min, max); + } else { + constrainedRange = upperYRange; + } + IrisCaveCarver3D carver = getCarver(weightedProfile.profile); + carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, + constrainedRange, ceilingSurfaceHeights, fullVerticalRange); + } + } + private List resolveWeightedProfiles(int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState) { BlendScratch blendScratch = BLEND_SCRATCH.get(); IrisCaveProfile[] profileField = blendScratch.profileField; diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index b23ec8368..17caf4a3e 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.modifier; +import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.actuator.IrisDecorantActuator; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedModifier; @@ -82,10 +83,22 @@ public class IrisCarveModifier extends EngineAssignedModifier { MatterCavern[] boundaryCaverns = scratch.boundaryCaverns; int[] surfaceHeights = scratch.surfaceHeights; Map customBiomeCache = scratch.customBiomeCache; + UpperDimensionContext upperCtx = getEngine().getUpperContext(); + boolean protectUpper = upperCtx != null && !getEngine().getDimension().isUpperDimensionCarving(); + int[] upperSurfaceHeights = protectUpper ? scratch.getOrCreateUpperSurfaceHeights() : null; + int chunkBlockX = PowerOfTwoCoordinates.chunkToBlock(x); + int chunkBlockZ = PowerOfTwoCoordinates.chunkToBlock(z); for (int columnIndex = 0; columnIndex < 256; columnIndex++) { int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; surfaceHeights[columnIndex] = context.getRoundedHeight(localX, localZ); + if (protectUpper) { + int worldX = localX + chunkBlockX; + int worldZ = localZ + chunkBlockZ; + int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ); + int gap = getEngine().getDimension().getUpperDimensionGap(); + upperSurfaceHeights[columnIndex] = Math.max(rawUpper, surfaceHeights[columnIndex] + gap); + } } try { @@ -102,6 +115,11 @@ public class IrisCarveModifier extends EngineAssignedModifier { int rx = xx & 15; int rz = zz & 15; int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz); + + if (upperSurfaceHeights != null && yy >= upperSurfaceHeights[columnIndex]) { + return; + } + BlockData current = output.getRaw(rx, yy, rz); if (B.isFluid(current)) { @@ -723,6 +741,7 @@ public class IrisCarveModifier extends EngineAssignedModifier { private final int[] surfaceHeights = new int[256]; private final PackedWallBuffer walls = new PackedWallBuffer(512); private final Map customBiomeCache = new HashMap<>(); + private int[] upperSurfaceHeights; private CarveScratch() { for (int index = 0; index < columnMasks.length; index++) { @@ -731,6 +750,13 @@ public class IrisCarveModifier extends EngineAssignedModifier { } } + private int[] getOrCreateUpperSurfaceHeights() { + if (upperSurfaceHeights == null) { + upperSurfaceHeights = new int[256]; + } + return upperSurfaceHeights; + } + private void reset() { for (int index = 0; index < columnMasks.length; index++) { columnMasks[index].clear(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index be2886877..8c60e8a10 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -177,6 +177,19 @@ public class IrisDimension extends IrisRegistrant { private IrisRange dimensionHeight = new IrisRange(-64, 320); @Desc("Define options for this dimension") private IrisDimensionTypeOptions dimensionOptions = new IrisDimensionTypeOptions(); + @Desc("When true, sets the dimension's ambient light to maximum, making all blocks fully lit regardless of light level.") + private boolean fullbright = false; + @RegistryListResource(IrisDimension.class) + @Desc("When set, generates the referenced dimension's terrain upside-down at the world ceiling, creating a nether-like canopy. Use the dimension's load key (e.g., 'overworld'). Set to 'none' to disable.") + private String upperDimension = "none"; + @MinNumber(0) + @MaxNumber(256) + @Desc("Minimum air gap in blocks between the lower terrain surface and the upper terrain surface.") + private int upperDimensionGap = 32; + @Desc("When true, cave carving will cut through the upper dimension terrain. When false, the upper terrain is a solid untouched mass.") + private boolean upperDimensionCarving = false; + @Desc("When true, objects from the mantle (structures, trees, etc.) can be placed in the upper dimension terrain zone. When false, the upper terrain is protected from object placement.") + private boolean upperDimensionObjects = false; @RegistryListResource(IrisBiome.class) @Desc("Keep this either undefined or empty. Setting any biome name into this will force iris to only generate the specified biome. Great for testing.") private String focus = ""; @@ -650,7 +663,15 @@ public class IrisDimension extends IrisRegistrant { } public IrisDimensionType getDimensionType() { - return new IrisDimensionType(getBaseDimension(), getDimensionOptions(), getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight()); + IrisDimensionTypeOptions options = getDimensionOptions(); + if (fullbright) { + options = options.copy().ambientLight(1.0f); + } + return new IrisDimensionType(getBaseDimension(), options, getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight()); + } + + public boolean hasUpperDimension() { + return upperDimension != null && !upperDimension.isEmpty() && !upperDimension.equalsIgnoreCase("none"); } public void installDimensionType(IDataFixer fixer, KList folders) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java b/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java index 827af7672..8e619e5a8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java @@ -21,6 +21,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.studio.generators.BiomeBuffetGenerator; +import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator; import java.util.function.Consumer; @@ -34,7 +35,7 @@ public enum StudioMode { BIOME_BUFFET_18x18(i -> i.setStudioGenerator(new BiomeBuffetGenerator(i.getEngine(), 18))), BIOME_BUFFET_36x36(i -> i.setStudioGenerator(new BiomeBuffetGenerator(i.getEngine(), 36))), REGION_BUFFET(i -> i.setStudioGenerator(null)), - OBJECT_BUFFET(i -> i.setStudioGenerator(null)), + OBJECT_BUFFET(i -> i.setStudioGenerator(new ObjectStudioGenerator(i.getEngine()))), ; diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 6d12c61c8..3d445e975 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -778,9 +778,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private void computeStudioGenerator() { - if (!getEngine().getDimension().getStudioMode().equals(lastMode)) { - lastMode = getEngine().getDimension().getStudioMode(); - getEngine().getDimension().getStudioMode().inject(this); + StudioMode desired = getEngine().getDimension().getStudioMode(); + if (studio && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) { + desired = StudioMode.OBJECT_BUFFET; + } + if (!desired.equals(lastMode)) { + lastMode = desired; + desired.inject(this); } } @@ -807,6 +811,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public boolean shouldGenerateStructures() { + if (isStudio() && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) { + return false; + } return IrisSettings.get().getGeneral().isAutoGenerateIntrinsicStructures(); } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java new file mode 100644 index 000000000..e5bc77c55 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java @@ -0,0 +1,289 @@ +/* + * 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.platform.studio.generators; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.runtime.ObjectStudioActivation; +import art.arcane.iris.core.runtime.ObjectStudioLayout; +import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell; +import art.arcane.iris.core.service.ObjectStudioSaveService; +import art.arcane.iris.engine.data.chunk.TerrainChunk; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.WrongEngineBroException; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator; +import art.arcane.iris.util.common.data.VectorMap; +import art.arcane.iris.util.common.math.Vector3i; +import org.bukkit.Material; +import org.bukkit.block.Biome; +import org.bukkit.block.data.BlockData; +import org.bukkit.util.BlockVector; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ObjectStudioGenerator extends EnginedStudioGenerator { + public static final int DEFAULT_PADDING = 2; + private static final BlockData FLOOR = Material.POLISHED_DEEPSLATE.createBlockData(); + private static final BlockData FRAME = Material.SMOOTH_QUARTZ.createBlockData(); + private static final BlockData MARKER = Material.END_ROD.createBlockData(); + private static final Biome DEFAULT_BIOME = Biome.PLAINS; + + private final int padding; + private final BlockData floor; + private final BlockData frame; + private final BlockData marker; + private final AtomicBoolean layoutBuilt = new AtomicBoolean(false); + private final Object layoutLock = new Object(); + private final Map objectCache = new ConcurrentHashMap<>(); + private final Map packData = new ConcurrentHashMap<>(); + private volatile ObjectStudioLayout layout; + + public ObjectStudioGenerator(Engine engine) { + this(engine, DEFAULT_PADDING, FLOOR, FRAME, MARKER); + } + + public ObjectStudioGenerator(Engine engine, int padding, BlockData floor, BlockData frame, BlockData marker) { + super(engine); + this.padding = padding; + this.floor = floor; + this.frame = frame; + this.marker = marker; + } + + public ObjectStudioLayout getLayout() { + return layout; + } + + public int getPadding() { + return padding; + } + + @Override + public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { + ensureLayout(engine); + + int floorY = Math.max(engine.getMinHeight(), ObjectStudioLayout.FLOOR_Y); + for (int bx = 0; bx < 16; bx++) { + for (int bz = 0; bz < 16; bz++) { + tc.setBiome(bx, floorY, bz, DEFAULT_BIOME); + tc.setBlock(bx, floorY, bz, floor); + } + } + + ObjectStudioLayout currentLayout = layout; + if (currentLayout == null) { + return; + } + + int chunkWorldX = x << 4; + int chunkWorldZ = z << 4; + int chunkMaxX = chunkWorldX + 15; + int chunkMaxZ = chunkWorldZ + 15; + int minHeight = engine.getMinHeight(); + int maxHeight = engine.getMaxHeight(); + + int plinthY = floorY + 1; + + for (GridCell cell : currentLayout.cells()) { + int frameMinX = cell.originX() - 1; + int frameMaxX = cell.originX() + cell.w(); + int frameMinZ = cell.originZ() - 1; + int frameMaxZ = cell.originZ() + cell.d(); + + if (frameMaxX < chunkWorldX || frameMinX > chunkMaxX) continue; + if (frameMaxZ < chunkWorldZ || frameMinZ > chunkMaxZ) continue; + + paintFrame(cell, tc, plinthY, chunkWorldX, chunkWorldZ, minHeight, maxHeight); + + IrisObject object = loadObject(cell); + if (object != null) { + placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight); + } + } + } + + private void paintFrame(GridCell cell, TerrainChunk tc, int plinthY, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) { + int x0 = cell.originX() - 1; + int x1 = cell.originX() + cell.w(); + int z0 = cell.originZ() - 1; + int z1 = cell.originZ() + cell.d(); + int topY = Math.min(maxHeight - 1, plinthY + cell.h() + 1); + + if (plinthY >= minHeight && plinthY < maxHeight) { + paintFrameRowX(tc, x0, x1, plinthY, z0, chunkWorldX, chunkWorldZ); + paintFrameRowX(tc, x0, x1, plinthY, z1, chunkWorldX, chunkWorldZ); + paintFrameRowZ(tc, x0, plinthY, z0, z1, chunkWorldX, chunkWorldZ); + paintFrameRowZ(tc, x1, plinthY, z0, z1, chunkWorldX, chunkWorldZ); + } + + if (topY > plinthY && topY < maxHeight) { + paintFrameRowX(tc, x0, x1, topY, z0, chunkWorldX, chunkWorldZ); + paintFrameRowX(tc, x0, x1, topY, z1, chunkWorldX, chunkWorldZ); + paintFrameRowZ(tc, x0, topY, z0, z1, chunkWorldX, chunkWorldZ); + paintFrameRowZ(tc, x1, topY, z0, z1, chunkWorldX, chunkWorldZ); + } + + int edgeLo = plinthY + 1; + int edgeHi = Math.min(topY - 1, maxHeight - 1); + if (edgeHi >= edgeLo) { + paintEdgePillar(tc, x0, edgeLo, edgeHi, z0, chunkWorldX, chunkWorldZ); + paintEdgePillar(tc, x1, edgeLo, edgeHi, z0, chunkWorldX, chunkWorldZ); + paintEdgePillar(tc, x0, edgeLo, edgeHi, z1, chunkWorldX, chunkWorldZ); + paintEdgePillar(tc, x1, edgeLo, edgeHi, z1, chunkWorldX, chunkWorldZ); + } + } + + private void paintEdgePillar(TerrainChunk tc, int x, int yMin, int yMax, int z, int chunkWorldX, int chunkWorldZ) { + if (x < chunkWorldX || x > chunkWorldX + 15) return; + if (z < chunkWorldZ || z > chunkWorldZ + 15) return; + int localX = x - chunkWorldX; + int localZ = z - chunkWorldZ; + for (int y = yMin; y <= yMax; y++) { + tc.setBlock(localX, y, localZ, marker); + } + } + + private void paintFrameRowX(TerrainChunk tc, int xMin, int xMax, int y, int z, int chunkWorldX, int chunkWorldZ) { + if (z < chunkWorldZ || z > chunkWorldZ + 15) return; + int lo = Math.max(xMin, chunkWorldX); + int hi = Math.min(xMax, chunkWorldX + 15); + for (int x = lo; x <= hi; x++) { + tc.setBlock(x - chunkWorldX, y, z - chunkWorldZ, frame); + } + } + + private void paintFrameRowZ(TerrainChunk tc, int x, int y, int zMin, int zMax, int chunkWorldX, int chunkWorldZ) { + if (x < chunkWorldX || x > chunkWorldX + 15) return; + int lo = Math.max(zMin, chunkWorldZ); + int hi = Math.min(zMax, chunkWorldZ + 15); + for (int z = lo; z <= hi; z++) { + tc.setBlock(x - chunkWorldX, y, z - chunkWorldZ, frame); + } + } + + private void placeSlice(IrisObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) { + VectorMap blocks = object.getBlocks(); + if (blocks == null || blocks.isEmpty()) return; + + Vector3i center = object.getCenter(); + int centerX = center == null ? 0 : center.getBlockX(); + int centerY = center == null ? 0 : center.getBlockY(); + int centerZ = center == null ? 0 : center.getBlockZ(); + + int originX = cell.originX(); + int originY = cell.originY(); + int originZ = cell.originZ(); + + for (Map.Entry entry : blocks) { + BlockVector signed = entry.getKey(); + int worldX = originX + signed.getBlockX() + centerX; + int worldY = originY + signed.getBlockY() + centerY; + int worldZ = originZ + signed.getBlockZ() + centerZ; + + if (worldX < chunkWorldX || worldX > chunkWorldX + 15) continue; + if (worldZ < chunkWorldZ || worldZ > chunkWorldZ + 15) continue; + if (worldY < minHeight || worldY >= maxHeight) continue; + + BlockData data = entry.getValue(); + if (data == null) continue; + + tc.setBlock(worldX - chunkWorldX, worldY, worldZ - chunkWorldZ, data); + } + } + + private IrisObject loadObject(GridCell cell) { + String cacheKey = cell.pack() + "/" + cell.key(); + IrisObject cached = objectCache.get(cacheKey); + if (cached != null) return cached; + IrisData data = packData.get(cell.pack()); + if (data == null) return null; + IrisObject loaded = data.getObjectLoader().load(cell.key()); + if (loaded != null) { + objectCache.put(cacheKey, loaded); + } + return loaded; + } + + public Map getPackData() { + return packData; + } + + private void ensureLayout(Engine engine) { + if (layoutBuilt.get()) return; + synchronized (layoutLock) { + if (layoutBuilt.get()) return; + + Map sources = resolveSources(engine); + packData.putAll(sources); + + File layoutFile = layoutFile(engine); + ObjectStudioLayout resumed = ObjectStudioLayout.load(layoutFile, sources, padding); + if (resumed != null) { + layout = resumed; + } else { + layout = ObjectStudioLayout.build(sources, padding); + layout.save(layoutFile); + } + layoutBuilt.set(true); + + try { + ObjectStudioSaveService.get().register(engine, this); + } catch (Throwable e) { + Iris.reportError(e); + } + + int cellCount = layout.cells().size(); + BlockVector worldExtent = computeExtent(layout); + Iris.info("Object Studio layout built: %d cells from %d pack(s), extent %d x %d blocks", + cellCount, sources.size(), worldExtent.getBlockX(), worldExtent.getBlockZ()); + } + } + + private Map resolveSources(Engine engine) { + String packKey = engine.getDimension() == null ? null : engine.getDimension().getLoadKey(); + Map registered = packKey == null ? null : ObjectStudioActivation.getSources(packKey); + if (registered != null && !registered.isEmpty()) { + return registered; + } + Map fallback = new LinkedHashMap<>(); + IrisData data = engine.getData(); + fallback.put(data.getDataFolder().getName(), data); + return fallback; + } + + private File layoutFile(Engine engine) { + File worldFolder = engine.getTarget().getWorld().worldFolder(); + return new File(new File(worldFolder, ".iris"), "object-studio-layout.json"); + } + + private static BlockVector computeExtent(ObjectStudioLayout layout) { + int maxX = 0; + int maxZ = 0; + for (GridCell cell : layout.cells()) { + maxX = Math.max(maxX, cell.originX() + cell.w()); + maxZ = Math.max(maxZ, cell.originZ() + cell.d()); + } + return new BlockVector(maxX, 0, maxZ); + } +}