diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/config/IrisHydrology.java b/core/src/main/java/art/arcane/iris/generation/hydrology/config/IrisHydrology.java index 81ea33e5f..153e7e7d5 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/config/IrisHydrology.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/config/IrisHydrology.java @@ -17,6 +17,11 @@ public class IrisHydrology { @Description("Turns rivers, streams, ponds, lakes and underground water on for this dimension.") private boolean enabled = false; + @MinNumber(0) + @MaxNumber(1) + @Description("Fraction of eligible water features to keep after local placement probabilities. 0.5 keeps half the candidate river sources, ponds, lakes and underground water features without changing their configured sizes.") + private double density = 1D; + @Description("How water drains across the terrain before rivers and ponds are placed.") private IrisHydrologyDrainage drainage = new IrisHydrologyDrainage(); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java index 340f50aee..3eb059957 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java @@ -60,6 +60,7 @@ public final class HydrologyRuntime { private static final int CHUNKS_PER_WINDOW = WindowKey.SIZE >> 4; public static final int MAX_PREGENERATION_REGIONS = 4096; private static final int MAX_PREVIEW_REQUESTS = 64; + private static final int PREFETCH_CAPACITY = 16; private static final HydrologyRuntime DISABLED = new HydrologyRuntime(); private final HydrologySettings settings; @@ -74,6 +75,7 @@ public final class HydrologyRuntime { private final RasterFailures windowPreviewFailures; private final RasterFailures regionPreviewFailures; private final Set previews; + private final LinkedHashMap prefetches; private final HydrologyBinding binding; private final Function stores; private final boolean caveFluidPolicies; @@ -94,6 +96,7 @@ public final class HydrologyRuntime { this.windowPreviewFailures = null; this.regionPreviewFailures = null; this.previews = null; + this.prefetches = null; this.binding = HydrologyBinding.UNBOUND; this.stores = identity -> null; this.caveFluidPolicies = false; @@ -119,6 +122,7 @@ public final class HydrologyRuntime { this.windowPreviewFailures = new RasterFailures(ticker); this.regionPreviewFailures = new RasterFailures(ticker); this.previews = new HashSet<>(); + this.prefetches = new LinkedHashMap<>(PREFETCH_CAPACITY * 2, 0.75f, true); this.binding = Objects.requireNonNull(binding, "binding"); this.stores = Objects.requireNonNull(stores, "stores"); this.caveFluidPolicies = caveFluidPolicies; @@ -135,6 +139,9 @@ public final class HydrologyRuntime { engine.getHeight()); boolean caveFluidPolicies = settings.underground().cavePools().enabled() && requireBiomeFluids(dimension.getReachableBiomes(engine), settings); + if (settings.density() == 0D) { + return DISABLED; + } HydrologyBinding binding = binding(engine, complex); IrisHydrologyTerrainSource source = new IrisHydrologyTerrainSource(complex, dimension, settings, MultiBurst.hydrology, MultiBurst.hydrology.parallelism(), binding); @@ -203,16 +210,24 @@ public final class HydrologyRuntime { return settings; } + public PreviewScope preview() { + return new PreviewScope(cache == null ? null : last.get()); + } + public ChunkWaterRaster raster(int chunkX, int chunkZ) { if (cache == null) { return ChunkWaterRaster.EMPTY; } + LastRaster slot = last.get(); + if (slot.preview) { + return rasterPreview(chunkX, chunkZ); + } if (mainThread.getAsBoolean()) { return rasterSnapshot(chunkX, chunkZ); } requireOpen(); long key = key(chunkX, chunkZ); - ChunkWaterRaster known = remembered(key); + ChunkWaterRaster known = remembered(key, slot); return known != null ? known : remember(key, planned(chunkX, chunkZ, key)); } @@ -224,7 +239,7 @@ public final class HydrologyRuntime { return ChunkWaterRaster.PROVISIONAL; } long key = key(chunkX, chunkZ); - ChunkWaterRaster known = remembered(key); + ChunkWaterRaster known = remembered(key, last.get()); if (known == null) { ChunkWaterRaster landed = queue(chunkX, chunkZ, key, false); return landed == null ? ChunkWaterRaster.PROVISIONAL : landed; @@ -240,7 +255,7 @@ public final class HydrologyRuntime { return ChunkWaterRaster.PROVISIONAL; } long key = key(chunkX, chunkZ); - ChunkWaterRaster known = remembered(key); + ChunkWaterRaster known = remembered(key, last.get()); if (known == null) { ChunkWaterRaster landed = queue(chunkX, chunkZ, key, true); return landed == null ? ChunkWaterRaster.PROVISIONAL : landed; @@ -426,6 +441,9 @@ public final class HydrologyRuntime { synchronized (rasters) { rasters.clear(); } + synchronized (prefetches) { + prefetches.clear(); + } } private static HydrologyBinding binding(Engine engine, IrisComplex complex) { @@ -532,7 +550,7 @@ public final class HydrologyRuntime { queued.remove(key); return null; } - ChunkWaterRaster landed = remembered(key); + ChunkWaterRaster landed = remembered(key, last.get()); if (landed != null) { finish(key, preview); return landed; @@ -632,8 +650,22 @@ public final class HydrologyRuntime { } private void prefetch(int chunkX, int chunkZ) { - if (settings.prefetchRings() > 0) { - cache.prepareWindowsAround(chunkX, chunkZ, chunkX, chunkZ, 1 + settings.prefetchRings()); + if (settings.prefetchRings() <= 0) { + return; + } + WindowKey owner = WindowKey.containing(chunkX << 4, chunkZ << 4); + synchronized (prefetches) { + long evictions = cache.windowEvictions(); + WindowPrefetch known = prefetches.get(owner); + if (known != null && !known.prepared().isCompletedExceptionally() + && (!known.prepared().isDone() || known.evictions() == evictions)) { + return; + } + CompletableFuture prepared = cache.prepareWindowsAround(chunkX, chunkZ, chunkX, chunkZ, 1 + settings.prefetchRings()); + prefetches.put(owner, new WindowPrefetch(prepared, evictions)); + if (prefetches.size() > PREFETCH_CAPACITY) { + prefetches.pollFirstEntry(); + } } } @@ -648,8 +680,7 @@ public final class HydrologyRuntime { return keys; } - private ChunkWaterRaster remembered(long key) { - LastRaster slot = last.get(); + private ChunkWaterRaster remembered(long key, LastRaster slot) { if (slot.raster != null && slot.key == key) { return slot.raster; } @@ -712,9 +743,13 @@ public final class HydrologyRuntime { } } + private record WindowPrefetch(CompletableFuture prepared, long evictions) { + } + private static final class LastRaster { private long key; private ChunkWaterRaster raster; + private boolean preview; private void remember(long key, ChunkWaterRaster raster) { this.key = key; @@ -722,6 +757,30 @@ public final class HydrologyRuntime { } } + public static final class PreviewScope implements AutoCloseable { + private final LastRaster slot; + private final boolean previous; + private boolean closed; + + private PreviewScope(LastRaster slot) { + this.slot = slot; + this.previous = slot != null && slot.preview; + if (slot != null) { + slot.preview = true; + } + } + + @Override + public void close() { + if (!closed) { + closed = true; + if (slot != null) { + slot.preview = previous; + } + } + } + } + private static final class RasterLru extends LinkedHashMap { private RasterLru() { super(RASTER_CAPACITY * 2, 0.75f, true); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/grid/HeightLattice.java b/core/src/main/java/art/arcane/iris/generation/hydrology/grid/HeightLattice.java index 0fd2b6ede..1f1d5c7f4 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/grid/HeightLattice.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/grid/HeightLattice.java @@ -225,8 +225,11 @@ public final class HeightLattice { } } - private static Long key(int cellX, int cellZ) { - return ((long) cellX << 32) | (cellZ & 0xFFFFFFFFL); + static long key(int cellX, int cellZ) { + long packed = ((long) cellX << 32) | (cellZ & 0xFFFFFFFFL); + long mixed = (packed ^ (packed >>> 30)) * 0xbf58476d1ce4e5b9L; + mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL; + return mixed ^ (mixed >>> 31); } private record Slot(CompletableFuture batch, int index) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java index ff359806c..f2d324cc5 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.hydrology.plan; +import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.hydrology.raster.ReachIndex; import art.arcane.iris.spi.IrisLogging; @@ -20,7 +21,9 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; import java.util.function.BiFunction; @@ -46,6 +49,7 @@ public final class HydrologyPlanCache { private volatile HydrologyPlanStore store; private int writing; private final ExecutorService pool; + private final int speculativeParallelism; private final BooleanSupplier mainThread; private final Object lock = new Object(); private final PriorityQueue queue = new PriorityQueue<>(Comparator.comparingLong((Job job) -> job.priority).thenComparingLong(job -> job.order)); @@ -56,6 +60,7 @@ public final class HydrologyPlanCache { private final Shelf regions = new Shelf<>(REGION_CAPACITY, this::startRegion); private final Shelf windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow); private final Map>> epochSlots = new HashMap<>(); + private int runningSpeculative; private volatile boolean closed; public HydrologyPlanCache(HydrologySettings settings, Function regionPlanner, @@ -64,6 +69,7 @@ public final class HydrologyPlanCache { this.windowPlanner = Objects.requireNonNull(windowPlanner, "windowPlanner"); this.persist = Objects.requireNonNull(settings, "settings").persist(); this.pool = Objects.requireNonNull(pool, "pool"); + this.speculativeParallelism = speculativeParallelism(pool); this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); } @@ -102,6 +108,10 @@ public final class HydrologyPlanCache { return windows.ready(Objects.requireNonNull(key, "key")); } + public long windowEvictions() { + return windows.evictions.get(); + } + public RegionPlan region(RegionKey key) { Objects.requireNonNull(key, "key"); requireOffMainThread("region " + key.rx() + "," + key.rz()); @@ -179,11 +189,7 @@ public final class HydrologyPlanCache { dropped.run(); return; } - try { - pool.execute(this::runNext); - } catch (RejectedExecutionException rejected) { - abandonQueue(rejected); - } + dispatch(); } private CompletableFuture prepare(int minChunkX, int minChunkZ, int maxChunkX, int maxChunkZ, int rings, Interest interest) { @@ -288,7 +294,17 @@ public final class HydrologyPlanCache { } private long speculativePriority() { - return -(speculations.incrementAndGet() << SPECULATIVE_SHIFT); + return Long.MAX_VALUE - (speculations.incrementAndGet() << SPECULATIVE_SHIFT); + } + + private static int speculativeParallelism(ExecutorService pool) { + int parallelism = switch (pool) { + case MultiBurst burst -> burst.parallelism(); + case ForkJoinPool forkJoin -> forkJoin.getParallelism(); + case ThreadPoolExecutor threads -> threads.getCorePoolSize(); + default -> 1; + }; + return Math.max(1, parallelism / 2); } private static int frontCells(int unit, int cell) { @@ -457,29 +473,34 @@ public final class HydrologyPlanCache { slot.fail(new CancellationException("Hydrology plan cache is closed.")); return; } - try { - pool.execute(this::runNext); - } catch (RejectedExecutionException rejected) { - abandonQueue(rejected); - } + dispatch(); } private void runNext() { Job job; + boolean speculative; synchronized (lock) { - job = queue.poll(); - if (job != null && job.slot != null) { + job = queue.peek(); + if (job == null) { + return; + } + speculative = job.priority >= 0; + if (speculative && runningSpeculative >= speculativeParallelism) { + return; + } + queue.remove(); + if (speculative) { + runningSpeculative++; + } + if (job.slot != null) { job.slot.job = null; } } - if (job == null) { - return; - } - if (closed) { - job.drop(new CancellationException("Hydrology plan cache is closed.")); - return; - } try { + if (closed) { + job.drop(new CancellationException("Hydrology plan cache is closed.")); + return; + } job.body.run(); } catch (Throwable failure) { if (job.slot != null) { @@ -487,6 +508,25 @@ public final class HydrologyPlanCache { } else if (!closed) { IrisLogging.reportError("A speculative hydrology task failed.", failure); } + } finally { + if (speculative) { + boolean pending; + synchronized (lock) { + runningSpeculative--; + pending = !closed && !queue.isEmpty(); + } + if (pending) { + dispatch(); + } + } + } + } + + private void dispatch() { + try { + pool.execute(this::runNext); + } catch (RejectedExecutionException rejected) { + abandonQueue(rejected); } } @@ -513,8 +553,12 @@ public final class HydrologyPlanCache { slot.priority = priority; Job job = slot.job; if (job != null && queue.remove(job)) { + boolean demanded = job.priority >= 0 && priority < 0; job.priority = priority; queue.add(job); + if (demanded) { + dispatch(); + } } for (Slot need : slot.needs) { promoteLocked(need, priority); @@ -597,6 +641,7 @@ public final class HydrologyPlanCache { private final class Shelf { private final ConcurrentHashMap> slots = new ConcurrentHashMap<>(); + private final AtomicLong evictions = new AtomicLong(); private final int capacity; private final BiConsumer> start; @@ -658,7 +703,9 @@ public final class HydrologyPlanCache { ready.sort(Comparator.comparingLong(Aged::used)); for (int index = 0; index < excess; index++) { Slot slot = ready.get(index).slot(); - slots.remove(slot.key, slot); + if (slots.remove(slot.key, slot)) { + evictions.incrementAndGet(); + } } } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologySettings.java b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologySettings.java index 47f2af635..3f5c9b07f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologySettings.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologySettings.java @@ -50,6 +50,7 @@ public final class HydrologySettings { private final int minY; private final int maxY; private final boolean enabled; + private final float density; private final Drainage drainage; private final List tiers; private final Ponds ponds; @@ -67,6 +68,10 @@ public final class HydrologySettings { this.minY = minY; this.maxY = maxY; this.enabled = config.isEnabled(); + if (!Double.isFinite(config.getDensity()) || config.getDensity() < 0D || config.getDensity() > 1D) { + throw new IllegalArgumentException("Hydrology density must be within 0..1, got " + config.getDensity()); + } + this.density = (float) config.getDensity(); this.drainage = drainage(config.getDrainage()); this.tiers = tiers(config.getTiers()); this.ponds = ponds(config.getPonds()); @@ -126,6 +131,10 @@ public final class HydrologySettings { return enabled; } + public float density() { + return density; + } + public Drainage drainage() { return drainage; } @@ -458,7 +467,7 @@ public final class HydrologySettings { private long computeFingerprint() { Fingerprint hash = new Fingerprint().add(seed).add(seaLevel).add(minY).add(maxY).add(enabled) - .add(prefetchRings).add(persist); + .add(density).add(prefetchRings).add(persist); addDrainage(hash); addTiers(hash); addPonds(hash); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/RegionPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/RegionPlanner.java index 438e16412..6590edd48 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/RegionPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/RegionPlanner.java @@ -3,6 +3,7 @@ package art.arcane.iris.generation.hydrology.plan; import art.arcane.iris.generation.hydrology.grid.Basin; import art.arcane.iris.generation.hydrology.grid.BasinExtractor; import art.arcane.iris.generation.hydrology.grid.Breacher; +import art.arcane.iris.generation.hydrology.grid.CellHash; import art.arcane.iris.generation.hydrology.grid.ChannelHeads; import art.arcane.iris.generation.hydrology.grid.D8Router; import art.arcane.iris.generation.hydrology.grid.DemGrid; @@ -28,6 +29,7 @@ public final class RegionPlanner { private static final int SPAN = 3 * REGION_CELLS; private static final float BASIN_DEPTH = 0.5f; private static final int BREACH_PASSES = 8; + private static final long SALT_HEAD = 0x4d414a4f524844L; private final HydrologySettings settings; private final HeightLattice lattice; @@ -127,7 +129,7 @@ public final class RegionPlanner { float[] accumulation = FlowAccumulator.accumulate(receivers, filled, weights, settings.drainage().saturationCatchment()); BitSet candidates = ChannelHeads.candidates(surface, accumulation, samples.permission(), settings.majorTier().minCatchment(), settings.seaLevel()); BitSet channels = ChannelHeads.keep(surface, samples.height(), receivers, candidates, settings.drainage().headRelief(), cell -> false, - cell -> true); + cell -> CellHash.unit(CellHash.mix(settings.seed(), SALT_HEAD, surface.blockX(cell), surface.blockZ(cell))) < settings.density()); return shaper.coarsePaths(surface, receivers, accumulation, channels, settings.seed()); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java index c3494f81d..5a120d456 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java @@ -94,11 +94,18 @@ public final class WindowPlanner { BitSet seeds = seeds(samples.ocean(), trunks); DemGrid preliminary = new DemGrid(raw.originX(), raw.originZ(), CELL, SPAN, SPAN); System.arraycopy(raw.values(), 0, preliminary.values(), 0, SPAN * SPAN); - BitSet channelHint = drain(preliminary, seeds, samples, trunks, lakes).channels(); + Drainage initial = drain(preliminary, seeds, samples, trunks, lakes); + BitSet channelHint = (BitSet) initial.channels().clone(); channelHint.andNot(trunks.cells()); CupSet cups = ponds.subtractCups(raw, key, samples, channelHint); - trunks.descend(raw.values()); - Drainage drainage = drain(raw, seeds, samples, trunks, lakes); + Drainage drainage; + if (cups.size() == 0) { + raw = preliminary; + drainage = initial; + } else { + trunks.descend(raw.values()); + drainage = drain(raw, seeds, samples, trunks, lakes); + } List water = new ArrayList<>(ponds.ponds(raw, drainage.filled(), drainage.basins(), lakes, fine, cups)); water.addAll(lakes); ShapedNetwork network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water, @@ -231,10 +238,8 @@ public final class WindowPlanner { private boolean keepHead(DemGrid surface, int cell, float rainfall) { HydrologySettings.Drainage drainage = settings.drainage(); - if (!drainage.wetnessThinning()) { - return true; - } - float keep = Math.max(drainage.minimumHeadKeep(), rainfall); + float keep = drainage.wetnessThinning() ? Math.clamp(Math.max(drainage.minimumHeadKeep(), rainfall), 0f, 1f) : 1f; + keep *= settings.density(); return CellHash.unit(CellHash.mix(settings.seed(), SALT_HEAD, surface.blockX(cell), surface.blockZ(cell))) < keep; } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/MaskEdges.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/MaskEdges.java index ee2de6aa2..0699f2890 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/MaskEdges.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/MaskEdges.java @@ -28,13 +28,20 @@ final class MaskEdges { int firstJ = Math.max(-margin, Math.floorDiv(minZ - mask.originZ() + half, step)); int lastJ = Math.min(mask.depth() - 1 + margin, Math.floorDiv(maxZ - mask.originZ() + half, step)); for (int j = firstJ; j <= lastJ; j++) { + int start = Integer.MIN_VALUE; for (int i = firstI; i <= lastI; i++) { if (edge(mask, i, j)) { - int x = mask.originX() + i * step - half; - int z = mask.originZ() + j * step - half; - append(x, z, x + step - 1, z + step - 1); + if (start == Integer.MIN_VALUE) { + start = i; + } + } else if (start != Integer.MIN_VALUE) { + appendRun(mask, start, i, j); + start = Integer.MIN_VALUE; } } + if (start != Integer.MIN_VALUE) { + appendRun(mask, start, lastI + 1, j); + } } slots++; starts[slots] = count; @@ -77,6 +84,14 @@ final class MaskEdges { squares[count++] = maxZ; } + private void appendRun(PondMask mask, int first, int end, int row) { + int step = mask.step(); + int half = step >> 1; + int x = mask.originX() + first * step - half; + int z = mask.originZ() + row * step - half; + append(x, z, mask.originX() + end * step - half - 1, z + step - 1); + } + enum Kind { WATER, BANK, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/underground/CavePoolFiller.java b/core/src/main/java/art/arcane/iris/generation/hydrology/underground/CavePoolFiller.java index 5616de96d..d1bf23062 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/underground/CavePoolFiller.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/underground/CavePoolFiller.java @@ -19,6 +19,7 @@ public final class CavePoolFiller { private static final long SALT_COLUMN = 0x434156434f4c00L; private static final long SALT_FLOOR = 0x4341564652L << 20; private static final long SALT_FREEBOARD = 0x43415646524244L; + private static final long SALT_DENSITY = 0x43415644454e53L; private static final HydrologyCaveCell GUARD = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); private static final CaveVoxel[] VOXELS = CaveVoxel.values(); private static final int[] DX = {1, -1, 0, 0, 0, 0}; @@ -31,18 +32,23 @@ public final class CavePoolFiller { private final HydrologySettings.CavePools pools; private final Set fluids; + private final float density; public CavePoolFiller(HydrologySettings settings) { this.pools = settings.underground().cavePools(); this.fluids = settings.fluids().keySet(); + this.density = settings.density(); } public void fill(int chunkX, int chunkZ, CarvedTerrain carved, MantleWriter writer, long seed) { - if (!pools.enabled()) { + if (!pools.enabled() || density == 0f) { return; } int attempts = attempts(seed, chunkX, chunkZ); for (int attempt = 0; attempt < attempts; attempt++) { + if (density < 1f && CellHash.unit(CellHash.mix(seed, SALT_DENSITY + attempt, chunkX, chunkZ)) >= density) { + continue; + } long column = CellHash.mix(seed, SALT_COLUMN + attempt, chunkX, chunkZ); int x = (chunkX << 4) + (int) (column & 15); int z = (chunkZ << 4) + (int) ((column >>> 4) & 15); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlanner.java index b2fba03ef..1ad8dea8a 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlanner.java @@ -32,6 +32,7 @@ public final class SeaInletPlanner { private static final long SALT_RADIUS = 0x53454152414449L; private static final long SALT_HEADROOM = 0x53454148454144L; private static final long SALT_DEPTH = 0x53454144455054L; + private static final long SALT_DENSITY = 0x53454144454e53L; private final HydrologySettings settings; @@ -42,7 +43,7 @@ public final class SeaInletPlanner { public List plan(DemGrid raw, HydrologySampleBuffer samples, int[] receivers, float[] accumulation, ShapedNetwork network, long seed) { HydrologySettings.SeaInlets config = settings.underground().seaInlets(); - if (!config.enabled()) { + if (!config.enabled() || settings.density() == 0f) { return List.of(); } int count = raw.values().length; @@ -101,6 +102,9 @@ public final class SeaInletPlanner { private SeaInlet site(Terrain terrain, HydrologySettings.SeaInlets config, long key, List candidates) { int siteX = (int) (key >> 32); int siteZ = (int) key; + if (settings.density() < 1f && CellHash.unit(CellHash.mix(terrain.seed, SALT_DENSITY, siteX, siteZ)) >= settings.density()) { + return null; + } double targetX = (siteX + CellHash.unit(CellHash.mix(terrain.seed, SALT_SITE_X, siteX, siteZ))) * config.spacing(); double targetZ = (siteZ + CellHash.unit(CellHash.mix(terrain.seed, SALT_SITE_Z, siteX, siteZ))) * config.spacing(); DemGrid grid = terrain.grid; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/water/CupCarver.java b/core/src/main/java/art/arcane/iris/generation/hydrology/water/CupCarver.java index 439d7233b..212241988 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/water/CupCarver.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/water/CupCarver.java @@ -72,7 +72,8 @@ final class CupCarver { } float slope = slope(raw, samples, cell); if (suitable(samples, cell, slope) - && CellHash.unit(CellHash.mix(seed, SALT_SITE, siteX, siteZ)) < chance * samples.pondDensity()[cell]) { + && CellHash.unit(CellHash.mix(seed, SALT_SITE, siteX, siteZ)) + < Math.clamp(chance * samples.pondDensity()[cell], 0D, 1D) * settings.density()) { addCup(raw, samples, cell, slope, siteX, siteZ, false, out); } } @@ -89,7 +90,7 @@ final class CupCarver { } float slope = slope(raw, samples, cell); double roll = CellHash.unit(CellHash.mix(settings.seed(), SALT_CHAIN, raw.blockX(cell), raw.blockZ(cell))); - if (suitable(samples, cell, slope) && roll < chance * samples.pondDensity()[cell]) { + if (suitable(samples, cell, slope) && roll < Math.clamp(chance * samples.pondDensity()[cell], 0D, 1D) * settings.density()) { sites.add(new Site(roll, cell, slope)); } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java index 6b27b6683..936299820 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java @@ -150,7 +150,7 @@ public final class PondPlanner { } } return new Pond(pond.id(), pond.kind(), level, (int) Math.floor(lowest), pond.spillX(), pond.spillZ(), pond.spillHeight(), - mask(box, wet), pond.outletReachId(), pond.damRaise(), pond.fluid(), pond.floorX(), pond.floorZ()); + mask(box, ground, wet, level), pond.outletReachId(), pond.damRaise(), pond.fluid(), pond.floorX(), pond.floorZ()); } } return null; @@ -223,13 +223,16 @@ public final class PondPlanner { return null; } if (kind == PondKind.NATURAL && CellHash.unit(CellHash.mix(settings.seed(), SALT_KEEP, floorX, floorZ)) - >= settings.ponds().natural().keep() * floor.pondDensity()[0]) { + >= Math.clamp(settings.ponds().natural().keep() * floor.pondDensity()[0], 0f, 1f) * settings.density()) { return null; } return new Candidate(basin, kind, floorX, floorZ, floor.rainfall()[0], fluid(floor)); } private Pond lake(LakeCandidate candidate, HeightLattice fine) { + if (CellHash.unit(CellHash.mix(settings.seed(), SALT_KEEP, candidate.floorX(), candidate.floorZ())) >= settings.density()) { + return null; + } HydrologySampleBuffer floor = sampleAt(fine, candidate.floorX(), candidate.floorZ()); if ((floor.permission()[0] & HydrologySampleBuffer.ALLOW_PONDS) == 0) { return null; @@ -347,36 +350,31 @@ public final class PondPlanner { level--; continue; } - int limit = rim == null ? level : Math.min(level, rimGround(rim, box, wet) + settings.deformation().maxFill()); + PondMask mask = mask(box, ground, wet, level); + int limit = rim == null ? level : Math.min(level, rimGround(rim, mask) + settings.deformation().maxFill()); if (limit >= level) { - return pond(site, level, box, ground, wet, spill, damRaise); + return pond(site, level, box, ground, wet, spill, damRaise, mask); } level = limit; } return null; } - private static int rimGround(HeightLattice lattice, Box box, boolean[] wet) { - int half = box.step() >> 1; + private static int rimGround(HeightLattice lattice, PondMask mask) { Set columns = new LinkedHashSet<>(); - for (int node = 0; node < wet.length; node++) { - if (!wet[node]) { - continue; - } - int i = node % box.width(); - int j = node / box.width(); - int x = box.x(node); - int z = box.z(node); - for (int axis = 0; axis < AXIS_X.length; axis++) { - int ni = i + AXIS_X[axis]; - int nj = j + AXIS_Z[axis]; - if (ni >= 0 && nj >= 0 && ni < box.width() && nj < box.depth() && wet[nj * box.width() + ni]) { + for (int j = 0; j < mask.depth(); j++) { + for (int i = 0; i < mask.width(); i++) { + if (!mask.node(i, j)) { continue; } - for (int along = -half; along < half; along++) { - int columnX = AXIS_X[axis] == 0 ? x + along : AXIS_X[axis] > 0 ? x + half : x - half - 1; - int columnZ = AXIS_Z[axis] == 0 ? z + along : AXIS_Z[axis] > 0 ? z + half : z - half - 1; - columns.add(((long) columnX << 32) | (columnZ & 0xFFFFFFFFL)); + for (int axis = 0; axis < AXIS_X.length; axis++) { + int ni = i + AXIS_X[axis]; + int nj = j + AXIS_Z[axis]; + if (!mask.node(ni, nj)) { + int x = mask.originX() + ni; + int z = mask.originZ() + nj; + columns.add(((long) x << 32) | (z & 0xFFFFFFFFL)); + } } } } @@ -397,7 +395,7 @@ public final class PondPlanner { return xs.length == 0 ? Integer.MAX_VALUE : (int) Math.floor(lowest); } - private Pond pond(Site site, int level, Box box, float[] ground, boolean[] wet, Spill spill, int damRaise) { + private Pond pond(Site site, int level, Box box, float[] ground, boolean[] wet, Spill spill, int damRaise, PondMask mask) { float lowest = Float.POSITIVE_INFINITY; for (int node = 0; node < wet.length; node++) { if (wet[node]) { @@ -406,7 +404,7 @@ public final class PondPlanner { } long id = CellHash.mix(settings.seed(), salt(site.kind()), site.floorX(), site.floorZ()); return new Pond(id, site.kind(), level, (int) Math.floor(lowest), spill.x(), spill.z(), spill.height(), - mask(box, wet), 0L, damRaise, site.fluid(), site.floorX(), site.floorZ()); + mask, 0L, damRaise, site.fluid(), site.floorX(), site.floorZ()); } private static boolean covered(DemGrid raw, Basin basin, List lakes) { @@ -681,7 +679,7 @@ public final class PondPlanner { return true; } - private static PondMask mask(Box box, boolean[] wet) { + private static PondMask mask(Box box, float[] ground, boolean[] wet, int level) { int minI = Integer.MAX_VALUE; int minJ = Integer.MAX_VALUE; int maxI = Integer.MIN_VALUE; @@ -698,6 +696,9 @@ public final class PondPlanner { minJ = Math.max(0, minJ - 1); maxI = Math.min(box.width() - 1, maxI + 1); maxJ = Math.min(box.depth() - 1, maxJ + 1); + if (box.step() < LAKE_STEP) { + return shorelineMask(box, ground, wet, level, minI, minJ, maxI, maxJ); + } int width = maxI - minI + 1; int depth = maxJ - minJ + 1; long[] bits = new long[(width * depth + 63) >>> 6]; @@ -712,6 +713,46 @@ public final class PondPlanner { return new PondMask(box.originX() + minI * box.step(), box.originZ() + minJ * box.step(), box.step(), width, depth, bits); } + private static PondMask shorelineMask(Box box, float[] ground, boolean[] wet, int level, int minI, int minJ, int maxI, int maxJ) { + int step = box.step(); + int half = step >> 1; + int width = (maxI - minI + 1) * step; + int depth = (maxJ - minJ + 1) * step; + long[] bits = new long[(width * depth + 63) >>> 6]; + for (int z = 0; z < depth; z++) { + int sampleZ = minJ * step + z - half; + int j = Math.floorDiv(sampleZ, step); + if (j < 0 || j + 1 >= box.depth()) { + continue; + } + float v = Math.floorMod(sampleZ, step) / (float) step; + for (int x = 0; x < width; x++) { + int sampleX = minI * step + x - half; + int i = Math.floorDiv(sampleX, step); + if (i < 0 || i + 1 >= box.width()) { + continue; + } + float u = Math.floorMod(sampleX, step) / (float) step; + int node = j * box.width() + i; + if (!wet[node] && !wet[node + 1] && !wet[node + box.width()] && !wet[node + box.width() + 1]) { + continue; + } + float first = lifted(box, ground, node) * (1f - u) + lifted(box, ground, node + 1) * u; + float second = lifted(box, ground, node + box.width()) * (1f - u) + + lifted(box, ground, node + box.width() + 1) * u; + if (first * (1f - v) + second * v < level) { + int index = z * width + x; + bits[index >>> 6] |= 1L << index; + } + } + } + return new PondMask(box.originX() + minI * step - half, box.originZ() + minJ * step - half, 1, width, depth, bits); + } + + private static float lifted(Box box, float[] ground, int node) { + return ground[node] - box.lift()[node]; + } + private int freeboard(Site site) { return CupCarver.pick(settings.ponds().freeboard(), CellHash.mix(settings.seed(), SALT_FREEBOARD, site.floorX(), site.floorZ())); diff --git a/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java b/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java index a207c9b4e..5bb45be01 100644 --- a/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java +++ b/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java @@ -119,6 +119,17 @@ public final class DesktopUiMessages { public static final TextKey PREGEN_EXISTING = TextKey.of("iris.desktop.pregen.existing", "Existing"); public static final TextKey PREGEN_NETWORK = TextKey.of("iris.desktop.pregen.network", "Network"); public static final TextKey PREGEN_TERRAIN_HINT = TextKey.of("iris.desktop.pregen.terrain_hint", "Finished chunks show terrain colors"); + public static final TextKey PREGEN_GRID = TextKey.of("iris.desktop.pregen.grid", "Grid"); + public static final TextKey PREGEN_GRID_HINT = TextKey.of("iris.desktop.pregen.grid_hint", "Show chunk and region borders (G)"); + public static final TextKey PREGEN_FIT = TextKey.of("iris.desktop.pregen.fit", "Reset view"); + public static final TextKey PREGEN_FIT_HINT = TextKey.of("iris.desktop.pregen.fit_hint", "Fit the whole generation area (R)"); + public static final TextKey PREGEN_ZOOM_IN = TextKey.of("iris.desktop.pregen.zoom_in", "Zoom in (+)"); + public static final TextKey PREGEN_ZOOM_OUT = TextKey.of("iris.desktop.pregen.zoom_out", "Zoom out (-)"); + public static final TextKey PREGEN_ZOOM = TextKey.of("iris.desktop.pregen.zoom", "Zoom {zoom}"); + public static final TextKey PREGEN_SHORTCUTS = TextKey.of("iris.desktop.pregen.shortcuts", "Shortcuts"); + public static final TextKey PREGEN_SHORTCUTS_HINT = TextKey.of("iris.desktop.pregen.shortcuts_hint", "Show mouse and key shortcuts (/)"); + public static final TextKey PREGEN_NAVIGATION_HINT = TextKey.of("iris.desktop.pregen.navigation_hint", "Drag to pan · Scroll to zoom · Hover to inspect chunks"); + public static final TextKey PREGEN_SHORTCUT_DETAILS = TextKey.of("iris.desktop.pregen.shortcut_details", "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"); public static final TextKey IMAGEMAP_TITLE = TextKey.of("iris.desktop.imagemap.title", "Image Map Studio"); public static final TextKey IMAGEMAP_PRESET = TextKey.of("iris.desktop.imagemap.preset", "Preset:"); public static final TextKey IMAGEMAP_LOAD = TextKey.of("iris.desktop.imagemap.load", "Load"); @@ -216,7 +227,9 @@ public final class DesktopUiMessages { PREGEN_METHOD, PREGEN_CACHED, PREGEN_FAILED, PREGEN_MAP, PREGEN_CHUNK_COORDINATES, PREGEN_WINDOW_HINT, PREGEN_BOUNDS, PREGEN_WAITING, PREGEN_READY, PREGEN_EXISTING, - PREGEN_NETWORK, PREGEN_TERRAIN_HINT, + PREGEN_NETWORK, PREGEN_TERRAIN_HINT, PREGEN_GRID, PREGEN_GRID_HINT, + PREGEN_FIT, PREGEN_FIT_HINT, PREGEN_ZOOM_IN, PREGEN_ZOOM_OUT, PREGEN_ZOOM, + PREGEN_SHORTCUTS, PREGEN_SHORTCUTS_HINT, PREGEN_NAVIGATION_HINT, PREGEN_SHORTCUT_DETAILS, IMAGEMAP_TITLE, IMAGEMAP_PRESET, IMAGEMAP_LOAD, IMAGEMAP_IMPORT_PNG, IMAGEMAP_REPLACE_PNG, IMAGEMAP_PREVIEW, IMAGEMAP_EXPORT, IMAGEMAP_METADATA, IMAGEMAP_RESOURCE, IMAGEMAP_COORDINATES, IMAGEMAP_BINDING_KEY, IMAGEMAP_MAP_KEY, diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index 06d8df5da..149cac3fd 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -388,10 +388,6 @@ public class StudioSVC implements IrisService { fingerprint, aliasPolicy ); - if (!fingerprint.equals(GenerationPackFingerprint.compute( - packRoot, GenerationPackFingerprint.CURRENT_VERSION))) { - throw new IOException("Generation pack changed during validation: " + packRoot); - } return new GenerationCandidate(fingerprint, dimensionContract, registryContract); } finally { data.close(); diff --git a/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java b/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java index f3edf1e66..aa3c048e4 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java +++ b/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java @@ -29,7 +29,7 @@ import java.awt.event.MouseEvent; * Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it, * always leaving the content some room beside it. */ -final class IslandOverlay extends JLayeredPane { +public final class IslandOverlay extends JLayeredPane { private static final long serialVersionUID = -4182209135360718342L; private final IslandPanel island; @@ -38,7 +38,7 @@ final class IslandOverlay extends JLayeredPane { private final int minimumUncoveredWidth; private int islandWidth; - IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) { + public IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) { this.island = island; this.background = background; this.islandWidth = islandWidth; diff --git a/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java b/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java index 0a92f0826..d0a3728c2 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java +++ b/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java @@ -34,13 +34,13 @@ import java.util.List; * islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the * island to resize it. */ -final class IslandPanel extends JPanel implements FloatingPanels.PartlySeeThrough { +public final class IslandPanel extends JPanel implements FloatingPanels.PartlySeeThrough { private static final long serialVersionUID = 7313084027614358519L; private static final int GAP = 6; private static final int CORNER_ARC = 12; private static final int INNER_PADDING = 4; - IslandPanel(JComponent content) { + public IslandPanel(JComponent content) { super(new BorderLayout()); setOpaque(false); int inset = GAP + INNER_PADDING; diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java b/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java index f8ec31de1..3142238e4 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java +++ b/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java @@ -724,10 +724,13 @@ final class MapCanvas extends JPanel { int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY); if (tile.tile() != null) { canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null); - continue; + if (!tile.provisional()) { + continue; + } + } else { + canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B); + canvas.fillRect(screenX, screenY, tileWidth, tileHeight); } - canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B); - canvas.fillRect(screenX, screenY, tileWidth, tileHeight); drawFallback(canvas, controller.fallback(frame, tile), screenX, screenY, tileWidth, tileHeight); } } diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapHost.java b/core/src/main/java/art/arcane/iris/studio/map/MapHost.java index bf09df18c..1f568be65 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/MapHost.java +++ b/core/src/main/java/art/arcane/iris/studio/map/MapHost.java @@ -46,7 +46,7 @@ public final class MapHost { return GuiHost.isAvailable(); } - static JFrame createFrame(String title) { + public static JFrame createFrame(String title) { XwaylandWindowManagerHint.apply(); StudioTheme.install(); JFrame frame = new JFrame(title); @@ -73,7 +73,7 @@ public final class MapHost { } // A scaled-up default size can be bigger than a small or low resolution screen. - static Dimension fitToScreen(Dimension preferred) { + public static Dimension fitToScreen(Dimension preferred) { Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height)); } diff --git a/core/src/main/java/art/arcane/iris/studio/map/StudioTheme.java b/core/src/main/java/art/arcane/iris/studio/map/StudioTheme.java index add8f5e4e..20bdaa1e2 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/StudioTheme.java +++ b/core/src/main/java/art/arcane/iris/studio/map/StudioTheme.java @@ -45,20 +45,20 @@ import java.util.concurrent.atomic.AtomicBoolean; * The colours of IntelliJ's "New UI", light and dark, shared by FlatLaf's controls and everything the studio windows * draw themselves. The theme follows the system unless the user picked light or dark. */ -final class StudioTheme { - static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22); - static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30); +public final class StudioTheme { + public static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22); + public static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30); static final Color HOVER_BACKGROUND = new ThemeColor(0xDFE1E5, 0x393B40); static final Color INACTIVE_SELECTION = new ThemeColor(0xDFE1E5, 0x43454A); static final Color SELECTION = new ThemeColor(0xD4E2FF, 0x2E436E); - static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157); + public static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157); static final Color SEPARATOR = new ThemeColor(0xEBECF0, 0x1E1F22); - static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5); - static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8); + public static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5); + public static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8); static final Color MUTED_TEXT = new ThemeColor(0x818594, 0x6F737A); - static final Color ACCENT = new Color(0x3574F0); + public static final Color ACCENT = new Color(0x3574F0); static final Color LINK = new ThemeColor(0x315FBD, 0x548AF7); - static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464); + public static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464); static final Color WARNING = new ThemeColor(0xC27D04, 0xF2C55C); static final Color SUCCESS = new ThemeColor(0x208A3C, 0x5FB865); static final Color OVERLAY_BACKGROUND = new ThemeColor(new Color(0xFF, 0xFF, 0xFF, 240), new Color(0x2B, 0x2D, 0x30, 236)); @@ -231,7 +231,7 @@ final class StudioTheme { /** * FlatLaf grows fonts and its own controls to the screen's DPI; every size the studio sets itself has to grow with them. */ - static int scale(int pixels) { + public static int scale(int pixels) { return Math.round(pixels * pixelScale); } @@ -239,27 +239,27 @@ final class StudioTheme { return pixels * pixelScale; } - static Dimension scaledSize(int width, int height) { + public static Dimension scaledSize(int width, int height) { return new Dimension(scale(width), scale(height)); } - static Border emptyBorder(int top, int left, int bottom, int right) { + public static Border emptyBorder(int top, int left, int bottom, int right) { return BorderFactory.createEmptyBorder(scale(top), scale(left), scale(bottom), scale(right)); } - static Font font() { + public static Font font() { return themeFont("defaultFont", Font.PLAIN, 13); } - static Font smallFont() { + public static Font smallFont() { return themeFont("small.font", Font.PLAIN, 11); } - static Font boldFont() { + public static Font boldFont() { return font().deriveFont(Font.BOLD); } - static Font titleFont() { + public static Font titleFont() { return font().deriveFont(Font.BOLD, font().getSize2D() + 1F); } diff --git a/core/src/main/java/art/arcane/iris/studio/map/StudioToolbar.java b/core/src/main/java/art/arcane/iris/studio/map/StudioToolbar.java index 335e2f9a1..f5e529777 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/StudioToolbar.java +++ b/core/src/main/java/art/arcane/iris/studio/map/StudioToolbar.java @@ -40,13 +40,13 @@ import java.awt.Insets; /** * Builds the IntelliJ style controls the studio windows share: a flat main toolbar, borderless toolbar buttons and search boxes. */ -final class StudioToolbar { +public final class StudioToolbar { private static final int TOOLBAR_HEIGHT_PADDING = 5; private StudioToolbar() { } - static JToolBar create() { + public static JToolBar create() { JToolBar toolbar = new JToolBar(); toolbar.setFloatable(false); toolbar.setRollover(true); @@ -65,7 +65,7 @@ final class StudioToolbar { return label; } - static JButton button(String text, String tooltip, Runnable action) { + public static JButton button(String text, String tooltip, Runnable action) { JButton button = toolbarButton(text, tooltip); button.addActionListener(event -> action.run()); return button; @@ -74,7 +74,7 @@ final class StudioToolbar { /** * Opens a menu to pick light, dark or the system's theme. */ - static JButton themeButton() { + public static JButton themeButton() { JButton button = toolbarButton("Theme", "Light, dark, or whatever the system uses"); button.addActionListener(event -> themeMenu().show(button, 0, button.getHeight())); return button; @@ -102,7 +102,7 @@ final class StudioToolbar { return button; } - static JToggleButton toggle(String text, String tooltip) { + public static JToggleButton toggle(String text, String tooltip) { JToggleButton toggle = new JToggleButton(text); toggle.setToolTipText(tooltip); toggle.setFocusable(false); @@ -134,7 +134,7 @@ final class StudioToolbar { return panel; } - static JScrollPane scroll(JComponent content) { + public static JScrollPane scroll(JComponent content) { JScrollPane scrollPane = new JScrollPane(content); scrollPane.setBorder(BorderFactory.createEmptyBorder()); scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); diff --git a/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java b/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java index 4a8c649fe..12795d6fd 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java +++ b/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java @@ -578,6 +578,10 @@ final class VisionRenderController implements AutoCloseable { return tile; } + boolean provisional() { + return provisional; + } + private void setTile(MapTile tile) { this.tile = tile; provisional = false; diff --git a/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java b/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java index bd93c3db9..419c4d628 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java +++ b/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java @@ -20,6 +20,7 @@ package art.arcane.iris.studio.map.components; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; @@ -166,7 +167,15 @@ public final class MapTileRenderer { } private double[] sampleHeights(TileGrid grid) { - return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights(); + IrisComplex complex = engine.getComplex(); + IrisDimension dimension = engine.getDimension(); + HydrologyRuntime water = complex.getHydrologyRuntime(); + if (water == null || !water.enabled() || dimension != null && dimension.getTerrainTransform() != null) { + return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights(); + } + try (HydrologyRuntime.PreviewScope ignored = water.preview()) { + return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights(); + } } // Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes. diff --git a/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java b/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java index d94001460..85c83919a 100644 --- a/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java +++ b/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java @@ -90,6 +90,7 @@ final class WaterMap { lakes.add(lake.id()); } Map rasters = new HashMap<>(); + WaterMap planned = null; for (int pixelZ = 0; pixelZ < resolution; pixelZ++) { checkCancelled(); int minimumZ = (int) Math.floor(startZ + pixelZ * step); @@ -102,6 +103,11 @@ final class WaterMap { ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4); if (raster.provisional()) { provisional = true; + if (planned == null) { + planned = new WaterMap(runtime, resolution, startX, startZ, step, cancelled); + planned.drawPlans(plans); + } + stamp(pixelX, pixelZ, planned.at(pixelX, pixelZ)); } else if (!raster.empty()) { stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes)); } diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java b/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java index 4f0c974af..44319fd40 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java @@ -2,32 +2,60 @@ package art.arcane.iris.studio.view; import art.arcane.iris.localization.DesktopUiMessages; import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.studio.map.StudioTheme; import art.arcane.volmlib.util.localization.MessageArgument; import javax.swing.JPanel; +import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import java.awt.Color; +import java.awt.Cursor; +import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.Point; import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.geom.AffineTransform; +import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; final class PregenMapPanel extends JPanel { + private static final double MIN_ZOOM = 0.25D; + private static final double MAX_ZOOM = 128D; + private static final double ZOOM_STEP = 1.25D; + private final PregenRenderSnapshot.Bounds bounds; private final PregenMapState state; private final PregenMapState.View view; private final BufferedImage image; + private final String[] shortcuts; private volatile boolean disposed; + private Runnable viewListener; + private Point drag; + private Point hover; + private double centerX = 0.5D; + private double centerZ = 0.5D; + private double zoom = 1D; + private int coveredLeftWidth; + private boolean gridVisible; + private boolean helpVisible; PregenMapPanel(PregenMapState state) { this.state = state; bounds = state.bounds(); image = new BufferedImage(state.width(), state.height(), BufferedImage.TYPE_INT_RGB); + image.setAccelerationPriority(0F); view = state.attach(((DataBufferInt) image.getRaster().getDataBuffer()).getData()); - setBackground(PregenRenderer.BACKGROUND); + shortcuts = IrisLanguage.plain(DesktopUiMessages.PREGEN_SHORTCUT_DETAILS).split("\n"); + setBackground(StudioTheme.EDITOR_BACKGROUND); + setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR)); + setFocusable(true); + installNavigation(); ToolTipManager.sharedInstance().registerComponent(this); } @@ -43,23 +71,83 @@ final class PregenMapPanel extends JPanel { void disposeMap() { disposed = true; + viewListener = null; ToolTipManager.sharedInstance().unregisterComponent(this); view.close(); } + void setViewListener(Runnable listener) { + viewListener = listener; + } + + void setCoveredLeftWidth(int width) { + coveredLeftWidth = Math.max(0, width); + changed(); + } + + void resetView() { + centerX = 0.5D; + centerZ = 0.5D; + zoom = 1D; + changed(); + } + + double zoom() { + return zoom; + } + + void zoomIn() { + zoomAt(ZOOM_STEP, (getWidth() + coveredLeftWidth) / 2D, getHeight() / 2D); + } + + void zoomOut() { + zoomAt(1D / ZOOM_STEP, (getWidth() + coveredLeftWidth) / 2D, getHeight() / 2D); + } + + void zoomAt(double factor, double anchorX, double anchorY) { + Rectangle2D.Double before = mapArea(); + double mapX = (anchorX - before.x) / before.width; + double mapZ = (anchorY - before.y) / before.height; + zoom = Math.clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM); + Rectangle2D.Double after = mapArea(); + centerX += mapX - (anchorX - after.x) / after.width; + centerZ += mapZ - (anchorY - after.y) / after.height; + changed(); + } + + void panBy(double pixelsX, double pixelsY) { + Rectangle2D.Double area = mapArea(); + centerX -= pixelsX / area.width; + centerZ -= pixelsY / area.height; + changed(); + } + + void toggleGrid() { + gridVisible = !gridVisible; + repaint(); + } + + boolean isGridVisible() { + return gridVisible; + } + + void toggleHelp() { + helpVisible = !helpVisible; + repaint(); + } + + void dismissHelp() { + helpVisible = false; + repaint(); + } + + String hoverCoordinates() { + return hover == null ? null : coordinatesAt(hover.x, hover.y); + } + @Override public String getToolTipText(MouseEvent event) { - if (disposed) { - return null; - } - Rectangle area = fit(bounds, getWidth(), getHeight()); - if (!area.contains(event.getX(), event.getY())) { - return null; - } - int chunkX = (int) (bounds.minX() + (long) (event.getX() - area.x) * bounds.width() / area.width); - int chunkZ = (int) (bounds.minZ() + (long) (event.getY() - area.y) * bounds.height() / area.height); - return IrisLanguage.plain(DesktopUiMessages.PREGEN_CHUNK_COORDINATES, - MessageArgument.trusted("x", chunkX), MessageArgument.trusted("z", chunkZ)); + return coordinatesAt(event.getX(), event.getY()); } @Override @@ -67,11 +155,20 @@ final class PregenMapPanel extends JPanel { super.paintComponent(graphics); Graphics2D canvas = (Graphics2D) graphics.create(); try { - Rectangle area = fit(bounds, getWidth(), getHeight()); + Rectangle2D.Double area = mapArea(); canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); - canvas.drawImage(image, area.x, area.y, area.width, area.height, null); - canvas.setColor(PregenRenderer.BORDER); - canvas.drawRect(area.x, area.y, area.width - 1, area.height - 1); + AffineTransform transform = AffineTransform.getTranslateInstance(area.x, area.y); + transform.scale(area.width / image.getWidth(), area.height / image.getHeight()); + canvas.drawImage(image, transform, null); + if (gridVisible) { + paintGrid(canvas, area); + } + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.draw(area); + paintHover(canvas, area); + if (helpVisible) { + paintHelp(canvas); + } } finally { canvas.dispose(); } @@ -85,4 +182,150 @@ final class PregenMapPanel extends JPanel { int mapHeight = Math.max(1, (int) Math.floor(bounds.height() * scale)); return new Rectangle((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight); } + + private Rectangle2D.Double mapArea() { + Rectangle fit = fit(bounds, Math.max(1, getWidth() - coveredLeftWidth), getHeight()); + double width = fit.width * zoom; + double height = fit.height * zoom; + return new Rectangle2D.Double(coveredLeftWidth + fit.getCenterX() - centerX * width, + fit.getCenterY() - centerZ * height, width, height); + } + + private String coordinatesAt(int x, int y) { + if (disposed || x < coveredLeftWidth) { + return null; + } + Rectangle2D.Double area = mapArea(); + if (!area.contains(x, y)) { + return null; + } + long chunkX = bounds.minX() + (long) Math.floor((x - area.x) * bounds.width() / area.width); + long chunkZ = bounds.minZ() + (long) Math.floor((y - area.y) * bounds.height() / area.height); + return IrisLanguage.plain(DesktopUiMessages.PREGEN_CHUNK_COORDINATES, + MessageArgument.trusted("x", chunkX), MessageArgument.trusted("z", chunkZ)); + } + + private void installNavigation() { + MouseAdapter navigation = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + if (SwingUtilities.isLeftMouseButton(event)) { + requestFocusInWindow(); + drag = event.getPoint(); + setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); + } + } + + @Override + public void mouseReleased(MouseEvent event) { + drag = null; + setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR)); + } + + @Override + public void mouseDragged(MouseEvent event) { + if (drag != null) { + hover = event.getPoint(); + panBy(event.getX() - drag.x, event.getY() - drag.y); + drag = hover; + } + } + + @Override + public void mouseMoved(MouseEvent event) { + hover = event.getPoint(); + changed(); + } + + @Override + public void mouseExited(MouseEvent event) { + hover = null; + changed(); + } + + @Override + public void mouseWheelMoved(MouseWheelEvent event) { + zoomAt(Math.pow(ZOOM_STEP, -event.getPreciseWheelRotation()), event.getX(), event.getY()); + } + }; + addMouseListener(navigation); + addMouseMotionListener(navigation); + addMouseWheelListener(navigation); + } + + private void paintGrid(Graphics2D canvas, Rectangle2D.Double area) { + if (!area.intersects(0, 0, getWidth(), getHeight())) { + return; + } + double chunkWidth = area.width / bounds.width(); + double chunkHeight = area.height / bounds.height(); + long step = Math.min(chunkWidth, chunkHeight) >= 24D ? 1 : 32; + while (step * Math.min(chunkWidth, chunkHeight) < 24D) { + step *= 2; + } + canvas.setColor(StudioTheme.CONTROL_BORDER); + long minX = Math.max(bounds.minX(), (long) Math.floor(bounds.minX() - area.x / chunkWidth)); + long maxX = Math.min((long) bounds.maxX() + 1, (long) Math.ceil(bounds.minX() + (getWidth() - area.x) / chunkWidth)); + long minZ = Math.max(bounds.minZ(), (long) Math.floor(bounds.minZ() - area.y / chunkHeight)); + long maxZ = Math.min((long) bounds.maxZ() + 1, (long) Math.ceil(bounds.minZ() + (getHeight() - area.y) / chunkHeight)); + int top = (int) Math.max(0, area.y); + int bottom = (int) Math.min(getHeight(), area.getMaxY()); + int left = (int) Math.max(0, area.x); + int right = (int) Math.min(getWidth(), area.getMaxX()); + for (long x = Math.ceilDiv(minX, step) * step; x <= maxX; x += step) { + int screenX = (int) Math.round(area.x + (x - bounds.minX()) * chunkWidth); + canvas.drawLine(screenX, top, screenX, bottom); + } + for (long z = Math.ceilDiv(minZ, step) * step; z <= maxZ; z += step) { + int screenZ = (int) Math.round(area.y + (z - bounds.minZ()) * chunkHeight); + canvas.drawLine(left, screenZ, right, screenZ); + } + } + + private void paintHover(Graphics2D canvas, Rectangle2D.Double area) { + if (hover == null || hover.x < coveredLeftWidth || !area.contains(hover)) { + return; + } + double chunkWidth = area.width / bounds.width(); + double chunkHeight = area.height / bounds.height(); + if (Math.min(chunkWidth, chunkHeight) < 4D) { + return; + } + double x = area.x + Math.floor((hover.x - area.x) / chunkWidth) * chunkWidth; + double y = area.y + Math.floor((hover.y - area.y) / chunkHeight) * chunkHeight; + canvas.setColor(StudioTheme.ACCENT); + canvas.draw(new Rectangle2D.Double(x, y, chunkWidth, chunkHeight)); + } + + private void paintHelp(Graphics2D canvas) { + canvas.setFont(StudioTheme.font()); + FontMetrics metrics = canvas.getFontMetrics(); + int padding = StudioTheme.scale(14); + int width = 0; + for (String line : shortcuts) { + width = Math.max(width, metrics.stringWidth(line)); + } + width += padding * 2; + int height = metrics.getHeight() * shortcuts.length + padding * 2; + int x = Math.max(coveredLeftWidth + padding, getWidth() - width - padding); + int arc = StudioTheme.scale(10); + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + canvas.setColor(StudioTheme.PANEL_BACKGROUND); + canvas.fillRoundRect(x, padding, width, height, arc, arc); + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.drawRoundRect(x, padding, width, height, arc, arc); + canvas.setColor(StudioTheme.TEXT); + int baseline = padding * 2 + metrics.getAscent(); + for (String line : shortcuts) { + canvas.drawString(line, x + padding, baseline); + baseline += metrics.getHeight(); + } + } + + private void changed() { + if (viewListener != null) { + viewListener.run(); + } + repaint(); + } } diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java b/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java index 4f5c424c6..93d171c16 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java @@ -10,7 +10,7 @@ import java.util.concurrent.locks.ReentrantLock; public final class PregenMapState { private static final int MAX_RASTER_SIZE = 1024; - static final Color WAITING = new Color(29, 35, 46); + static final Color WAITING = new Color(57, 59, 64); private final PregenRenderSnapshot.Bounds bounds; private final int width; diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java b/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java index 3b076c895..d71a29f95 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java @@ -22,13 +22,18 @@ import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.localization.DesktopUiMessages; import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.studio.map.IslandOverlay; +import art.arcane.iris.studio.map.IslandPanel; +import art.arcane.iris.studio.map.MapHost; +import art.arcane.iris.studio.map.StudioTheme; +import art.arcane.iris.studio.map.StudioToolbar; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.TextKey; import javax.swing.AbstractAction; -import javax.swing.AbstractButton; import javax.swing.BorderFactory; +import javax.swing.Box; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JFrame; @@ -36,30 +41,31 @@ import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JProgressBar; import javax.swing.JScrollPane; -import javax.swing.Scrollable; import javax.swing.JTextArea; +import javax.swing.JToggleButton; +import javax.swing.JToolBar; import javax.swing.KeyStroke; +import javax.swing.Scrollable; import javax.swing.SwingUtilities; import javax.swing.Timer; -import javax.swing.plaf.basic.BasicButtonUI; -import javax.swing.plaf.basic.BasicGraphicsUtils; -import javax.swing.plaf.basic.BasicProgressBarUI; -import javax.swing.plaf.basic.BasicScrollBarUI; +import javax.swing.text.DefaultCaret; +import javax.swing.text.JTextComponent; import java.awt.BorderLayout; import java.awt.Color; +import java.awt.Component; import java.awt.Dimension; -import java.awt.GridBagConstraints; -import java.awt.GridBagLayout; -import java.awt.Insets; import java.awt.Font; import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; import java.awt.GridLayout; -import java.awt.Graphics; +import java.awt.Insets; +import java.awt.KeyboardFocusManager; import java.awt.LayoutManager; import java.awt.Rectangle; import java.awt.event.ActionEvent; -import java.awt.event.FocusAdapter; -import java.awt.event.FocusEvent; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import java.awt.event.KeyEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; @@ -67,43 +73,43 @@ import java.util.Objects; public final class PregenRenderer extends JPanel { private static final long serialVersionUID = 2094606939770332040L; - static final Color BACKGROUND = new Color(12, 15, 22); - static final Color BORDER = new Color(105, 119, 139); - private static final Color SURFACE = new Color(17, 20, 29); - private static final Color FIELD = new Color(29, 34, 47); - private static final Color TEXT = new Color(230, 234, 242); - private static final Color MUTED = new Color(163, 173, 191); - private static final Color ACCENT = new Color(101, 194, 149); - private static final Color ERROR = new Color(255, 139, 139); + private static final Color BACKGROUND = StudioTheme.EDITOR_BACKGROUND; + private static final Color BORDER = StudioTheme.CONTROL_BORDER; + private static final Color SURFACE = StudioTheme.PANEL_BACKGROUND; + private static final Color TEXT = StudioTheme.TEXT; + private static final Color MUTED = StudioTheme.SECONDARY_TEXT; + private static final Color ACCENT = StudioTheme.ACCENT; + private static final Color ERROR = StudioTheme.ERROR; static final Color GENERATING = new Color(204, 168, 107); - static final Color GENERATED = ACCENT; + static final Color GENERATED = new Color(101, 194, 149); static final Color EXISTS = new Color(93, 133, 112); static final Color NETWORK = new Color(157, 138, 176); static final Color NETWORK_GENERATING = new Color(129, 112, 145); - private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 13); - private static final Font LABEL_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 11); - private static final Font VALUE_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 15); private final PregenRenderSource source; private final Runnable onPause; private final PregenMapPanel map; private final Timer repaintTimer; - private final JLabel worldLabel = label("", new Font(Font.SANS_SERIF, Font.BOLD, 21), TEXT); - private final JLabel phaseLabel = label("", BODY_FONT, MUTED); - private final JLabel countLabel = label("", BODY_FONT, TEXT); - private final JLabel percentLabel = label("", new Font(Font.SANS_SERIF, Font.BOLD, 24), TEXT); - private final JLabel currentValue = label("", VALUE_FONT, TEXT); - private final JLabel overallValue = label("", VALUE_FONT, TEXT); - private final JLabel thirtyValue = label("", VALUE_FONT, TEXT); - private final JLabel sixtyValue = label("", VALUE_FONT, TEXT); - private final JLabel etaValue = label("", VALUE_FONT, TEXT); - private final JLabel elapsedValue = label("", VALUE_FONT, TEXT); - private final JLabel memoryValue = label("", VALUE_FONT, TEXT); - private final JLabel pressureValue = label("", VALUE_FONT, TEXT); - private final JLabel methodLabel = label("", LABEL_FONT, MUTED); - private final JTextArea failureLabel = wrappedText("", BODY_FONT, ERROR, SURFACE); + private final JLabel worldLabel = label("", StudioTheme.titleFont(), TEXT); + private final JLabel phaseLabel = label("", StudioTheme.font(), MUTED); + private final JLabel countLabel = label("", StudioTheme.font(), TEXT); + private final JLabel percentLabel = label("", StudioTheme.titleFont(), TEXT); + private final JLabel currentValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel overallValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel thirtyValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel sixtyValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel etaValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel elapsedValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel memoryValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel pressureValue = label("", StudioTheme.boldFont(), TEXT); + private final JLabel methodLabel = label("", StudioTheme.smallFont(), MUTED); + private final JTextArea failureLabel = wrappedText("", StudioTheme.font(), ERROR, SURFACE); private String controlFailure; - private final JButton pauseButton = new JButton(); + private final JButton pauseButton = StudioToolbar.button("", "", this::togglePause); + private final JToggleButton gridToggle = StudioToolbar.toggle(text(DesktopUiMessages.PREGEN_GRID), + text(DesktopUiMessages.PREGEN_GRID_HINT)); + private final JLabel coordinatesLabel = label("", StudioTheme.smallFont(), MUTED); + private final JLabel zoomLabel = label("", StudioTheme.smallFont(), MUTED); private final JProgressBar progressBar = new JProgressBar(0, 10_000); private volatile boolean renderingEnabled; private volatile boolean disposed; @@ -116,16 +122,23 @@ public final class PregenRenderer extends JPanel { failureLabel.setFocusable(true); PregenRenderSnapshot initial = source.renderSnapshot(); map = new PregenMapPanel(source.renderMapState()); - setLayout(new BorderLayout(0, 18)); + setLayout(new BorderLayout()); setBackground(BACKGROUND); - setBorder(BorderFactory.createEmptyBorder(20, 24, 18, 24)); - add(header(), BorderLayout.NORTH); - JPanel content = panel(new BorderLayout(18, 0), BACKGROUND); - content.add(mapArea(initial.bounds()), BorderLayout.CENTER); - content.add(statusArea(), BorderLayout.EAST); - add(content, BorderLayout.CENTER); - add(wrappedText(text(DesktopUiMessages.PREGEN_WINDOW_HINT), LABEL_FONT, MUTED, BACKGROUND), BorderLayout.SOUTH); - installPauseControl(); + map.getAccessibleContext().setAccessibleName(text(DesktopUiMessages.PREGEN_MAP)); + map.setViewListener(this::updateMapStatus); + IslandPanel sidebar = new IslandPanel(statusArea(initial.bounds())); + sidebar.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + map.setCoveredLeftWidth(sidebar.getWidth()); + } + }); + add(toolbar(), BorderLayout.NORTH); + add(new IslandOverlay(sidebar, map, StudioTheme.scale(300), StudioTheme.scale(260), + StudioTheme.scale(300)), BorderLayout.CENTER); + add(statusBar(), BorderLayout.SOUTH); + installControls(); + updateMapStatus(); repaintTimer = new Timer(IrisSettings.get().getGui().isMaximumPregenGuiFPS() ? 4 : 250, event -> refresh()); refresh(); @@ -135,14 +148,14 @@ public final class PregenRenderer extends JPanel { if (!SwingUtilities.isEventDispatchThread()) { throw new IllegalStateException("The pregeneration window must open on the Swing event thread"); } + JFrame frame = MapHost.createFrame(title); PregenRenderer renderer = new PregenRenderer(source, onPause); - JFrame frame = new JFrame(title); - GuiHost.prepareFrame(frame); renderer.frame = frame; renderer.renderingEnabled = true; frame.setContentPane(renderer); - frame.setSize(1040, 760); - frame.setMinimumSize(new Dimension(760, 620)); + frame.setSize(MapHost.fitToScreen(StudioTheme.scaledSize(1240, 800))); + frame.setMinimumSize(StudioTheme.scaledSize(820, 520)); + frame.setLocationRelativeTo(null); frame.addWindowListener(new WindowAdapter() { @Override public void windowClosed(WindowEvent event) { @@ -172,67 +185,80 @@ public final class PregenRenderer extends JPanel { } } - private JPanel header() { - JPanel heading = panel(new BorderLayout(16, 0), BACKGROUND); - JPanel identity = panel(new GridLayout(2, 1, 0, 4), BACKGROUND); - identity.add(worldLabel); - identity.add(phaseLabel); - heading.add(identity, BorderLayout.CENTER); - heading.add(pauseButton, BorderLayout.EAST); - JPanel counts = panel(new BorderLayout(), BACKGROUND); - counts.add(countLabel, BorderLayout.WEST); - counts.add(percentLabel, BorderLayout.EAST); - progressBar.setUI(new BasicProgressBarUI()); - progressBar.setForeground(ACCENT); - progressBar.setBackground(FIELD); - progressBar.setBorderPainted(false); - progressBar.setPreferredSize(new Dimension(1, 6)); - progressBar.setFocusable(false); - JPanel progress = panel(new BorderLayout(0, 9), BACKGROUND); - progress.add(counts, BorderLayout.NORTH); - progress.add(progressBar, BorderLayout.SOUTH); - JPanel header = panel(new BorderLayout(0, 18), BACKGROUND); - header.add(heading, BorderLayout.NORTH); - header.add(progress, BorderLayout.SOUTH); - return header; + private JToolBar toolbar() { + JToolBar toolbar = StudioToolbar.create(); + worldLabel.setBorder(StudioTheme.emptyBorder(0, 6, 0, 14)); + worldLabel.setMaximumSize(StudioTheme.scaledSize(220, 36)); + worldLabel.setMinimumSize(StudioTheme.scaledSize(80, 24)); + toolbar.add(worldLabel); + toolbar.add(pauseButton); + toolbar.addSeparator(); + toolbar.add(StudioToolbar.button(text(DesktopUiMessages.PREGEN_FIT), + text(DesktopUiMessages.PREGEN_FIT_HINT), map::resetView)); + toolbar.add(StudioToolbar.button("−", text(DesktopUiMessages.PREGEN_ZOOM_OUT), map::zoomOut)); + toolbar.add(StudioToolbar.button("+", text(DesktopUiMessages.PREGEN_ZOOM_IN), map::zoomIn)); + gridToggle.addActionListener(event -> toggleGrid()); + toolbar.add(gridToggle); + toolbar.add(Box.createHorizontalGlue()); + toolbar.add(StudioToolbar.themeButton()); + toolbar.add(StudioToolbar.button(text(DesktopUiMessages.PREGEN_SHORTCUTS), + text(DesktopUiMessages.PREGEN_SHORTCUTS_HINT), map::toggleHelp)); + for (Component control : toolbar.getComponents()) { + if (control instanceof JButton || control instanceof JToggleButton) { + control.setFocusable(true); + } + } + return toolbar; } - private JPanel mapArea(PregenRenderSnapshot.Bounds bounds) { - JPanel area = panel(new BorderLayout(0, 8), BACKGROUND); - map.getAccessibleContext().setAccessibleName(text(DesktopUiMessages.PREGEN_MAP)); - area.add(map, BorderLayout.CENTER); - JLabel coordinates = label(text(DesktopUiMessages.PREGEN_BOUNDS, - MessageArgument.trusted("minX", Form.f(bounds.minX())), - MessageArgument.trusted("maxX", Form.f(bounds.maxX())), - MessageArgument.trusted("minZ", Form.f(bounds.minZ())), - MessageArgument.trusted("maxZ", Form.f(bounds.maxZ()))), LABEL_FONT, MUTED); - JPanel mapFooter = panel(new BorderLayout(0, 10), BACKGROUND); - mapFooter.add(coordinates, BorderLayout.NORTH); - mapFooter.add(legend(), BorderLayout.CENTER); - area.add(mapFooter, BorderLayout.SOUTH); - return area; + private JPanel statusBar() { + JPanel status = panel(new BorderLayout(12, 0), SURFACE); + status.setBorder(StudioTheme.emptyBorder(5, 12, 5, 12)); + status.add(coordinatesLabel, BorderLayout.CENTER); + status.add(zoomLabel, BorderLayout.EAST); + status.setToolTipText(text(DesktopUiMessages.PREGEN_WINDOW_HINT)); + return status; } - private JScrollPane statusArea() { + private void updateMapStatus() { + String coordinates = map.hoverCoordinates(); + coordinatesLabel.setText(coordinates == null ? text(DesktopUiMessages.PREGEN_NAVIGATION_HINT) : coordinates); + zoomLabel.setText(text(DesktopUiMessages.PREGEN_ZOOM, + MessageArgument.trusted("zoom", Form.pc(map.zoom(), 0)))); + } + + private JScrollPane statusArea(PregenRenderSnapshot.Bounds bounds) { JPanel status = new StatusPanel(); status.setBackground(SURFACE); - status.setBorder(BorderFactory.createEmptyBorder(18, 16, 16, 16)); + status.setBorder(StudioTheme.emptyBorder(14, 12, 12, 12)); GridBagConstraints constraints = new GridBagConstraints(); constraints.gridx = 0; constraints.gridy = 0; constraints.weightx = 1; constraints.fill = GridBagConstraints.HORIZONTAL; constraints.anchor = GridBagConstraints.NORTHWEST; - constraints.insets = new Insets(0, 0, 18, 0); + constraints.insets = new Insets(0, 0, StudioTheme.scale(12), 0); + status.add(label(text(DesktopUiMessages.PREGEN_TITLE), StudioTheme.titleFont(), TEXT), constraints); + constraints.gridy++; + status.add(phaseLabel, constraints); + constraints.gridy++; + JPanel progress = panel(new BorderLayout(0, StudioTheme.scale(8)), SURFACE); + progress.add(percentLabel, BorderLayout.NORTH); + progressBar.setForeground(ACCENT); + progressBar.setBackground(BACKGROUND); + progressBar.setBorderPainted(false); + progressBar.setPreferredSize(StudioTheme.scaledSize(1, 4)); + progressBar.setFocusable(false); + progress.add(progressBar, BorderLayout.CENTER); + progress.add(countLabel, BorderLayout.SOUTH); + status.add(progress, constraints); + constraints.gridy++; status.add(failureLabel, constraints); constraints.gridy++; - currentValue.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 25)); - etaValue.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 23)); - status.add(metric(DesktopUiMessages.PREGEN_CURRENT, currentValue), constraints); + status.add(metricRow(DesktopUiMessages.PREGEN_CURRENT, currentValue), constraints); constraints.gridy++; - status.add(metric(DesktopUiMessages.PREGEN_ETA, etaValue), constraints); + status.add(metricRow(DesktopUiMessages.PREGEN_ETA, etaValue), constraints); constraints.gridy++; - constraints.insets = new Insets(0, 0, 10, 0); status.add(metricRow(DesktopUiMessages.PREGEN_OVERALL, overallValue), constraints); constraints.gridy++; status.add(metricRow(DesktopUiMessages.PREGEN_THIRTY, thirtyValue), constraints); @@ -247,47 +273,26 @@ public final class PregenRenderer extends JPanel { constraints.gridy++; status.add(methodLabel, constraints); constraints.gridy++; + status.add(legend(), constraints); + constraints.gridy++; + status.add(wrappedText(text(DesktopUiMessages.PREGEN_BOUNDS, + MessageArgument.trusted("minX", Form.f(bounds.minX())), + MessageArgument.trusted("maxX", Form.f(bounds.maxX())), + MessageArgument.trusted("minZ", Form.f(bounds.minZ())), + MessageArgument.trusted("maxZ", Form.f(bounds.maxZ()))), StudioTheme.smallFont(), MUTED, SURFACE), constraints); + constraints.gridy++; + status.add(wrappedText(text(DesktopUiMessages.PREGEN_WINDOW_HINT), StudioTheme.smallFont(), MUTED, SURFACE), constraints); + constraints.gridy++; constraints.weighty = 1; status.add(panel(new BorderLayout(), SURFACE), constraints); - JScrollPane scroll = new JScrollPane(status); - scroll.setBorder(BorderFactory.createLineBorder(BORDER)); + JScrollPane scroll = StudioToolbar.scroll(status); scroll.getViewport().setBackground(SURFACE); - scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); - scroll.setPreferredSize(new Dimension(276, 1)); - scroll.setMinimumSize(new Dimension(260, 0)); - scroll.getVerticalScrollBar().setUnitIncrement(16); - scroll.getVerticalScrollBar().setUI(new BasicScrollBarUI() { - @Override - protected void configureScrollBarColors() { - thumbColor = BORDER; - thumbDarkShadowColor = BORDER; - thumbHighlightColor = BORDER; - thumbLightShadowColor = BORDER; - trackColor = FIELD; - } - - @Override - protected JButton createDecreaseButton(int orientation) { - return scrollEnd(); - } - - @Override - protected JButton createIncreaseButton(int orientation) { - return scrollEnd(); - } - - private JButton scrollEnd() { - JButton button = new JButton(); - button.setPreferredSize(new Dimension(0, 0)); - button.setFocusable(false); - return button; - } - }); return scroll; } private JPanel legend() { - JPanel legend = panel(new GridLayout(0, 2, 12, 7), BACKGROUND); + JPanel legend = panel(new GridLayout(0, 2, StudioTheme.scale(12), StudioTheme.scale(7)), SURFACE); + legend.setBorder(StudioTheme.emptyBorder(8, 0, 4, 0)); legend.add(legendItem(DesktopUiMessages.PREGEN_WAITING, PregenMapState.WAITING)); legend.add(legendItem(DesktopUiMessages.PREGEN_GENERATING, GENERATING)); legend.add(legendItem(DesktopUiMessages.PREGEN_READY, GENERATED)); @@ -298,64 +303,27 @@ public final class PregenRenderer extends JPanel { } private JPanel legendItem(TextKey key, Color color) { - JPanel item = panel(new BorderLayout(7, 0), BACKGROUND); + JPanel item = panel(new BorderLayout(StudioTheme.scale(7), 0), SURFACE); JPanel swatch = panel(new BorderLayout(), color); - swatch.setPreferredSize(new Dimension(10, 10)); + swatch.setPreferredSize(StudioTheme.scaledSize(10, 10)); swatch.setBorder(BorderFactory.createLineBorder(BORDER)); - JPanel marker = panel(new BorderLayout(), BACKGROUND); + JPanel marker = panel(new BorderLayout(), SURFACE); marker.add(swatch, BorderLayout.NORTH); item.add(marker, BorderLayout.WEST); - item.add(wrappedText(text(key), LABEL_FONT, MUTED, BACKGROUND), BorderLayout.CENTER); + item.add(wrappedText(text(key), StudioTheme.smallFont(), MUTED, SURFACE), BorderLayout.CENTER); return item; } - private JPanel metric(TextKey title, JLabel value) { - JPanel metric = panel(new BorderLayout(0, 5), SURFACE); - metric.add(wrappedText(text(title), LABEL_FONT, MUTED, SURFACE), BorderLayout.NORTH); - metric.add(value, BorderLayout.CENTER); - return metric; - } - private JPanel metricRow(TextKey title, JLabel value) { - JPanel row = panel(new BorderLayout(8, 0), SURFACE); - value.setFont(BODY_FONT); - row.add(wrappedText(text(title), LABEL_FONT, MUTED, SURFACE), BorderLayout.CENTER); + JPanel row = panel(new BorderLayout(StudioTheme.scale(8), 0), SURFACE); + row.add(wrappedText(text(title), StudioTheme.smallFont(), MUTED, SURFACE), BorderLayout.CENTER); row.add(value, BorderLayout.EAST); return row; } - private void installPauseControl() { - pauseButton.setUI(new BasicButtonUI() { - @Override - protected void paintText(Graphics graphics, AbstractButton button, Rectangle bounds, String text) { - if (button.isEnabled()) { - super.paintText(graphics, button, bounds, text); - return; - } - graphics.setColor(MUTED); - BasicGraphicsUtils.drawStringUnderlineCharAt(graphics, text, button.getDisplayedMnemonicIndex(), - bounds.x, bounds.y + graphics.getFontMetrics().getAscent()); - } - }); - pauseButton.setBackground(FIELD); - pauseButton.setForeground(TEXT); - pauseButton.setFont(BODY_FONT); + private void installControls() { + pauseButton.setFocusable(true); pauseButton.putClientProperty("html.disable", Boolean.TRUE); - pauseButton.setFocusPainted(false); - pauseButton.setPreferredSize(new Dimension(108, 46)); - setPauseBorder(false); - pauseButton.addFocusListener(new FocusAdapter() { - @Override - public void focusGained(FocusEvent event) { - setPauseBorder(true); - } - - @Override - public void focusLost(FocusEvent event) { - setPauseBorder(false); - } - }); - pauseButton.addActionListener(event -> togglePause()); pauseButton.getInputMap(JComponent.WHEN_FOCUSED).put( KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "activatePause"); pauseButton.getActionMap().put("activatePause", new AbstractAction() { @@ -364,19 +332,39 @@ public final class PregenRenderer extends JPanel { pauseButton.doClick(); } }); - getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0, true), "pause"); - getActionMap().put("pause", new AbstractAction() { + bind(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0, true), "pause", this::togglePause); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "reset-view", map::resetView); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_BACK_SLASH, 0), "reset-zoom", map::resetView); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, 0), "zoom-in", map::zoomIn); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK), "zoom-in-plus", map::zoomIn); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0), "zoom-in-keypad", map::zoomIn); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, 0), "zoom-out", map::zoomOut); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, 0), "zoom-out-keypad", map::zoomOut); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_G, 0), "grid", this::toggleGrid); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "pan-left", () -> map.panBy(64, 0)); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "pan-right", () -> map.panBy(-64, 0)); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "pan-up", () -> map.panBy(0, 64)); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "pan-down", () -> map.panBy(0, -64)); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_SLASH, 0), "help", map::toggleHelp); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK), "help-question", map::toggleHelp); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "dismiss", map::dismissHelp); + } + + private void bind(KeyStroke key, String name, Runnable action) { + getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(key, name); + getActionMap().put(name, new AbstractAction() { @Override public void actionPerformed(ActionEvent event) { - togglePause(); + if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) { + action.run(); + } } }); } - private void setPauseBorder(boolean focused) { - pauseButton.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(focused ? ACCENT : BORDER, 2), - BorderFactory.createEmptyBorder(8, 14, 8, 14))); + private void toggleGrid() { + map.toggleGrid(); + gridToggle.setSelected(map.isGridVisible()); } private void togglePause() { @@ -512,7 +500,10 @@ public final class PregenRenderer extends JPanel { } private static JTextArea wrappedText(String text, Font font, Color foreground, Color background) { - JTextArea area = new JTextArea(text); + JTextArea area = new JTextArea(); + DefaultCaret caret = (DefaultCaret) area.getCaret(); + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + area.setText(text); area.setEditable(false); area.setFocusable(false); area.setLineWrap(true); diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java index d19ae13d2..61537a3ed 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java @@ -158,13 +158,6 @@ public final class GenerationRegistryContractFactory { CustomBiomeAliasPolicy requiredAliasPolicy = Objects.requireNonNull(aliasPolicy, "aliasPolicy"); requireRuntimeIdentity(requiredGenerationRegistry); Path packRoot = requiredData.getDataFolder().toPath().toAbsolutePath().normalize(); - String actualPackFingerprint = GenerationPackFingerprint.compute( - packRoot, - GenerationPackFingerprint.CURRENT_VERSION - ); - if (!requiredPackFingerprint.equals(actualPackFingerprint)) { - throw new IOException("Iris generation registry capture rejected a changed pack."); - } Map definitions = new LinkedHashMap<>(); @@ -221,7 +214,15 @@ public final class GenerationRegistryContractFactory { ReferencedPlatformKeys referenced = referencedPlatformKeys(packRoot, requiredRegistries); addPlatformDefinitions(definitions, BLOCK_REGISTRY, referenced.blockKeys(), requiredGenerationRegistry); addPlatformDefinitions(definitions, ENTITY_TYPE_REGISTRY, referenced.entityKeys(), requiredGenerationRegistry); - return contract(definitions, generatedSources, requiredGenerationRegistry).withBiomeTags(biomeTags); + GenerationRegistryContract captured = contract(definitions, generatedSources, requiredGenerationRegistry).withBiomeTags(biomeTags); + String actualPackFingerprint = GenerationPackFingerprint.compute( + packRoot, + GenerationPackFingerprint.CURRENT_VERSION + ); + if (!requiredPackFingerprint.equals(actualPackFingerprint)) { + throw new IOException("Iris generation registry capture rejected a changed pack."); + } + return captured; } public static CustomBiomeDefinition customBiomeDefinition( diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index a01d9ab14..ac104f0ca 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -1937,6 +1937,17 @@ ready = "Bereit" existing = "Vorhanden" network = "Netzwerk" terrain_hint = "Fertige Chunks zeigen Geländefarben" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 34561560d..67a126d4b 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -1937,6 +1937,17 @@ ready = "Listo" existing = "Existente" network = "Red" terrain_hint = "Los chunks terminados muestran los colores del terreno" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index f613c2bf0..ba0c528eb 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -1942,6 +1942,17 @@ ready = "Valmis" existing = "Olemassa" network = "Verkko" terrain_hint = "Valmiit lohkot näyttävät maaston värit" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index 7e912d71d..06d165bd6 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -1937,6 +1937,17 @@ ready = "Prêt" existing = "Existant" network = "Réseau" terrain_hint = "Les chunks terminés affichent les couleurs du terrain" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index 5e706503f..c63fe427d 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -1942,6 +1942,17 @@ ready = "מוכן" existing = "קיים" network = "רשת" terrain_hint = "צ׳אנקים שהושלמו מציגים את צבעי השטח" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 98d35404a..faf9fd758 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -1937,6 +1937,17 @@ ready = "Pronto" existing = "Esistente" network = "Rete" terrain_hint = "I chunk completati mostrano i colori del terreno" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index e4d3703f3..5eb012f67 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -1940,6 +1940,17 @@ ready = "準備完了" existing = "既存" network = "ネットワーク" terrain_hint = "完了したチャンクは地形の色で表示されます" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index 6a7787a3b..6be20b39c 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -1940,6 +1940,17 @@ ready = "준비됨" existing = "기존" network = "네트워크" terrain_hint = "완료된 청크는 지형 색상으로 표시됩니다" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index d767f7ae1..92d539d4b 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -1942,6 +1942,17 @@ ready = "Paruošta" existing = "Esamos" network = "Tinklas" terrain_hint = "Baigtos sritys rodo reljefo spalvas" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index e3ca6468c..ac4c21989 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -1937,6 +1937,17 @@ ready = "Gereed" existing = "Bestaand" network = "Netwerk" terrain_hint = "Voltooide chunks tonen terreinkleuren" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index eee3c4037..bb47f8283 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -1942,6 +1942,17 @@ ready = "Gotowe" existing = "Istniejące" network = "Sieć" terrain_hint = "Gotowe chunki pokazują kolory terenu" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index 063279718..e6211a15f 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -1937,6 +1937,17 @@ ready = "Pronto" existing = "Existente" network = "Rede" terrain_hint = "Os chunks concluídos mostram as cores do terreno" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index 24f96057e..0bfd51311 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -1942,6 +1942,17 @@ ready = "Готово" existing = "Существующие" network = "Сеть" terrain_hint = "Готовые чанки отображают цвета ландшафта" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index f94dbaa90..1746239ab 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -1942,6 +1942,17 @@ ready = "Hazır" existing = "Mevcut" network = "Ağ" terrain_hint = "Tamamlanan chunklar arazi renklerini gösterir" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 1ace850e0..334bc1861 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -1940,6 +1940,17 @@ ready = "Sẵn sàng" existing = "Có sẵn" network = "Mạng" terrain_hint = "Chunk hoàn tất hiển thị màu địa hình" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index d8596a833..0c9952473 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -1940,6 +1940,17 @@ ready = "就绪" existing = "已有" network = "网络" terrain_hint = "已完成的区块显示地形颜色" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index e838e2b73..ea663861c 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -1940,6 +1940,17 @@ ready = "就緒" existing = "既有" network = "網路" terrain_hint = "已完成的區塊顯示地形顏色" +grid = "Grid" +grid_hint = "Show chunk and region borders (G)" +fit = "Reset view" +fit_hint = "Fit the whole generation area (R)" +zoom_in = "Zoom in (+)" +zoom_out = "Zoom out (-)" +zoom = "Zoom {zoom}" +shortcuts = "Shortcuts" +shortcuts_hint = "Show mouse and key shortcuts (/)" +navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks" +shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts" [iris.runtime.what] material = "Material: {material}" diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java index 887ec73d6..83df8ce68 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java @@ -53,6 +53,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.AbstractExecutorService; import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -79,8 +80,10 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -296,6 +299,31 @@ public class HydrologyRuntimeTest { } } + @Test(timeout = 180_000L) + public void chunksSharePrefetchAndRetryAfterEvictionOrFailure() { + FakePlanners planners = new FakePlanners(settings()); + HydrologyPlanCache cache = spy(new HydrologyPlanCache(planners.settings, planners::region, planners::window, + pool(planners, 2), () -> false)); + AtomicInteger prefetches = new AtomicInteger(); + doAnswer(invocation -> prefetches.incrementAndGet() == 2 + ? CompletableFuture.failedFuture(new IllegalStateException("prefetch failed")) : CompletableFuture.completedFuture(null)) + .when(cache).prepareWindowsAround(anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); + AtomicLong evictions = new AtomicLong(); + doAnswer(invocation -> evictions.get()).when(cache).windowEvictions(); + HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, planners::natural, () -> false, planners.ticker::get); + runtimes.add(runtime); + runtime.raster(0, 0); + runtime.raster(1, 0); + assertEquals(1, prefetches.get()); + evictions.incrementAndGet(); + runtime.raster(2, 0); + assertEquals(2, prefetches.get()); + runtime.raster(3, 0); + assertEquals(3, prefetches.get()); + runtime.raster(4, 0); + assertEquals(3, prefetches.get()); + } + @Test(timeout = 180_000L) public void preparePregenerationPlansRegionsInOrder() throws Exception { FakePlanners planners = new FakePlanners(settings()); @@ -702,6 +730,44 @@ public class HydrologyRuntimeTest { verify(engine, never()).getComplex(); } + @Test + public void zeroDensitySkipsRuntimePlanningAfterValidatingSettings() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisDimension dimension = new IrisDimension(); + dimension.setHydrology(new IrisHydrology().setEnabled(true).setDensity(0D)); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getSeedManager()).thenReturn(new SeedManager(1337L)); + when(engine.getHeight()).thenReturn(384); + + assertDisabled(HydrologyRuntime.create(engine, complex)); + verifyNoInteractions(complex); + verify(engine, never()).getPlatformHooks(); + + dimension.getHydrology().setDensity(-1D); + assertThrows(IllegalArgumentException.class, () -> HydrologyRuntime.create(engine, complex)); + } + + @Test(timeout = 15_000L) + public void nestedPreviewScopesStayNonblockingAndRestoreDemandSampling() throws Exception { + try (FakeHydrology water = new FakeHydrology(() -> false, (x, z) -> 60, 40, 63)) { + HydrologyRuntime runtime = spy(water.runtime); + water.hold(); + try (HydrologyRuntime.PreviewScope outer = runtime.preview()) { + assertTrue(runtime.raster(0, 0).provisional()); + try (HydrologyRuntime.PreviewScope inner = runtime.preview()) { + assertTrue(runtime.raster(1, 0).provisional()); + } + assertTrue(runtime.raster(0, 1).provisional()); + } + water.release(); + clearInvocations(runtime); + + assertFalse(runtime.raster(400, 400).provisional()); + verify(runtime, never()).rasterPreview(anyInt(), anyInt()); + } + } + @Test(timeout = 300_000L) public void createSavesPlansUnderTheWorldFolder() throws Exception { Path worldFolder = temporaryFolder.newFolder("world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/grid/HeightLatticeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/grid/HeightLatticeTest.java index 59d16b08b..0016c469a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/grid/HeightLatticeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/grid/HeightLatticeTest.java @@ -4,6 +4,7 @@ import org.junit.Test; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Random; import java.util.Set; @@ -32,6 +33,35 @@ public class HeightLatticeTest { private static final int AREA = 3 * WINDOW; private static final int DISTINCT_ACROSS_NINE = 25 * WINDOW * WINDOW; + @Test + public void neighbouringCellsSpreadAcrossConcurrentMapBuckets() { + int[] buckets = new int[65_536]; + int occupied = 0; + int largest = 0; + Set identities = new HashSet<>(); + for (int z = -96; z < 96; z++) { + for (int x = -96; x < 96; x++) { + long key = HeightLattice.key(x, z); + assertTrue(identities.add(key)); + int hash = Long.hashCode(key); + int bucket = (hash ^ (hash >>> 16)) & (buckets.length - 1); + if (buckets[bucket]++ == 0) { + occupied++; + } + largest = Math.max(largest, buckets[bucket]); + } + } + assertTrue("occupied buckets " + occupied, occupied > 25_000); + assertTrue("largest collision chain " + largest, largest < 12); + identities.clear(); + int[] extremes = {Integer.MIN_VALUE, Integer.MIN_VALUE + 1, -1, 0, 1, Integer.MAX_VALUE - 1, Integer.MAX_VALUE}; + for (int x : extremes) { + for (int z : extremes) { + assertTrue("distinct coordinate " + x + "," + z, identities.add(HeightLattice.key(x, z))); + } + } + } + @Test public void samplesEachCellOnceAcrossNineOverlappingWindows() { CountingSource source = new CountingSource(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java index b5f8b87e3..26976399d 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java @@ -317,6 +317,41 @@ public class HydrologyPlanCacheTest { assertEquals(26, planners.windowCalls.size()); } + @Test + public void speculativePlanningLeavesWorkersForNewAndPromotedDemand() throws Exception { + for (int threads : List.of(2, 4)) { + FakePlanners planners = new FakePlanners(unpersisted()); + Gate gate = new Gate(); + planners.hook = key -> { + if (key instanceof RegionKey region && Math.abs(region.rx()) < 10 && Math.abs(region.rz()) < 10) { + gate.pass(); + } + }; + ExecutorService workers = pool(threads); + HydrologyPlanCache cache = cache(planners, workers, () -> false); + CompletableFuture prepared = cache.prepareWindowsAround(0, 0, 31, 31, 2); + try { + await(() -> gate.arrivals() >= threads / 2, "speculative planning reaches its concurrency limit"); + WindowKey demanded = new WindowKey(400, 400); + assertEquals(demanded, cache.demandWindow(demanded).get(5L, TimeUnit.SECONDS).plan().key()); + assertEquals("prefetch leaves workers available for demand", threads / 2, gate.arrivals()); + + WindowKey promoted = new WindowKey(800, 800); + CompletableFuture prefetched = cache.prefetchWindow(promoted); + workers.submit(() -> null).get(5L, TimeUnit.SECONDS); + assertFalse("the prefetch waits for speculative capacity", prefetched.isDone()); + IndexedWindow ready = cache.demandWindow(promoted).get(5L, TimeUnit.SECONDS); + assertEquals(promoted, ready.plan().key()); + assertSame("demand promotes the queued prefetch", ready, prefetched.get(5L, TimeUnit.SECONDS)); + assertEquals(threads / 2, gate.arrivals()); + } finally { + gate.release(); + } + prepared.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals("all prepared windows finish after demand", 27, planners.windowCalls.size()); + } + } + @Test public void plansWithRealPlannersAndReloadsFromStore() throws Exception { HydrologySettings settings = settings(new IrisHydrology()); @@ -412,13 +447,14 @@ public class HydrologyPlanCacheTest { } @Test - public void leastRecentlyUsedWindowIsEvicted() { + public void leastRecentlyUsedWindowIsEvicted() throws Exception { FakePlanners planners = new FakePlanners(unpersisted()); HydrologyPlanCache cache = cache(planners, pool(4), () -> false); WindowKey kept = new WindowKey(0, 0); WindowKey stale = new WindowKey(1, 0); IndexedWindow keptWindow = cache.window(kept); cache.window(stale); + assertEquals(0L, cache.windowEvictions()); List requested = new ArrayList<>(List.of(kept, stale)); for (int index = 0; index < 300; index++) { WindowKey key = new WindowKey(index, 7); @@ -435,8 +471,13 @@ public class HydrologyPlanCacheTest { } } assertTrue("the cache stays bounded: " + ready, ready < requested.size() / 2); + await(() -> cache.windowEvictions() == requested.size() - 128, "all excess windows are evicted"); + long evictions = cache.windowEvictions(); + assertSame(keptWindow, cache.window(kept)); + assertEquals("a resident cache hit does not evict a window", evictions, cache.windowEvictions()); cache.window(stale); assertEquals("an evicted window is planned again on demand", 2, planners.windowCalls(stale)); + await(() -> cache.windowEvictions() == evictions + 1, "replanning an evicted window advances the eviction count"); } @Test diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologySettingsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologySettingsTest.java index c1ff97b21..fde9171aa 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologySettingsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologySettingsTest.java @@ -30,7 +30,20 @@ public class HydrologySettingsTest { private static final int SEA_LEVEL = 62; private static final int MIN_Y = -64; private static final int MAX_Y = 320; - private static final long DEFAULT_FINGERPRINT = 3973071586549334460L; + private static final long DEFAULT_FINGERPRINT = 8818472145657699942L; + + @Test + public void densityIsBoundedAndChangesThePlanIdentity() { + HydrologySettings full = compile(new IrisHydrology()); + HydrologySettings half = compile(new IrisHydrology().setDensity(0.5D)); + assertEquals(1f, full.density(), 0f); + assertEquals(0.5f, half.density(), 0f); + assertTrue(full.fingerprint() != half.fingerprint()); + assertEquals(full.tiers(), half.tiers()); + for (double invalid : new double[]{-0.01D, 1.01D, Double.NaN, Double.POSITIVE_INFINITY}) { + assertRejected(new IrisHydrology().setDensity(invalid), "density"); + } + } @Rule public final TemporaryFolder temporary = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/RegionPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/RegionPlannerTest.java index 7b1e5c8a9..9183dd532 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/RegionPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/RegionPlannerTest.java @@ -34,6 +34,14 @@ public class RegionPlannerTest { private static final RegionKey ORIGIN = new RegionKey(0, 0); private static final RegionKey EAST = new RegionKey(1, 0); + @Test + public void zeroDensityRemovesMajorChannelsAndLakes() { + HydrologySettings settings = HydrologySettings.compile(new IrisHydrology().setDensity(0D), SEED, SEA_LEVEL, + SyntheticTerrain.MIN_Y, SyntheticTerrain.MAX_Y); + assertTrue(planner(settings, SyntheticTerrain.coneIsland(2048D, 2048D, 1700D, 240D, 6)).plan(ORIGIN).majorReaches().isEmpty()); + assertTrue(planner(settings, SyntheticTerrain.crater(2048D, 2048D, 150, 700D)).plan(ORIGIN).lakes().isEmpty()); + } + @Test public void coneIslandMajorReachesEndInOcean() { HydrologySettings settings = settings(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/WindowPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/WindowPlannerTest.java index 83ce7068c..3221f6a68 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/WindowPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/WindowPlannerTest.java @@ -41,6 +41,14 @@ public class WindowPlannerTest { private static final WindowKey LAND = new WindowKey(4, 4); private static final int SEAM_DISTANCE = 256; + @Test + public void zeroDensityRemovesLocalChannelsAndPonds() { + HydrologySettings settings = settings(new IrisHydrology().setDensity(0D)); + WindowPlan plan = planner(settings, SyntheticTerrain.trunkValley()).plan(LAND, noRegions(LAND)); + assertTrue(plan.reaches().isEmpty()); + assertTrue(plan.ponds().isEmpty()); + } + @Test public void burnedTrunkCapturesLocalDrainage() { HydrologySettings settings = settings(new IrisHydrology()); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/underground/CavePoolFillerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/underground/CavePoolFillerTest.java index ba6c945e5..2d6b4cc30 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/underground/CavePoolFillerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/underground/CavePoolFillerTest.java @@ -28,12 +28,51 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class CavePoolFillerTest { private static final long SEED = 1337L; private static final int FLOOR = 50; + @Test + public void globalDensityThinsCandidateColumnsBeforeSamplingCaves() { + IrisHydrology config = new IrisHydrology().setDensity(0.5D); + config.getUnderground().getCavePools().setDensity(8D); + CavePoolFiller filler = new CavePoolFiller(HydrologySettings.compile(config, SEED, 63, 0, 384)); + CarvedTerrain carved = mock(CarvedTerrain.class); + MantleWriter writer = mock(MantleWriter.class); + Set columns = new HashSet<>(); + when(carved.surface(anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int z = invocation.getArgument(1); + columns.add(key(x, 0, z)); + return 0; + }); + for (int chunkX = 0; chunkX < 1024; chunkX++) { + filler.fill(chunkX, 0, carved, writer, SEED); + } + assertTrue("half density samples approximately half the candidate columns", columns.size() > 3600 && columns.size() < 4500); + Set repeated = new HashSet<>(columns); + columns.clear(); + for (int chunkX = 1023; chunkX >= 0; chunkX--) { + filler.fill(chunkX, 0, carved, writer, SEED); + } + assertEquals(repeated, columns); + verifyNoInteractions(writer); + } + + @Test + public void zeroGlobalDensityDoesNotReadCaves() { + IrisHydrology config = new IrisHydrology().setDensity(0D); + config.getUnderground().getCavePools().setDensity(8D); + CavePoolFiller filler = new CavePoolFiller(HydrologySettings.compile(config, SEED, 63, 0, 384)); + CarvedTerrain carved = mock(CarvedTerrain.class); + MantleWriter writer = mock(MantleWriter.class); + filler.fill(0, 0, carved, writer, SEED); + verifyNoInteractions(carved, writer); + } + @Test public void poolLevelIsHighestContainedLevelMinusFreeboard() { VoxelWorld world = roomWithSill(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlannerTest.java index bf15f6ed9..247c55fae 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/underground/SeaInletPlannerTest.java @@ -21,6 +21,31 @@ import static org.junit.Assert.assertTrue; public class SeaInletPlannerTest { private static final int SEA = InletTerrain.SEA; + @Test + public void globalDensityThinsInletSitesWithoutChangingTheirGeometry() { + InletTerrain terrain = InletTerrain.cliff(30D); + IrisHydrology full = new IrisHydrology(); + IrisHydrology half = new IrisHydrology().setDensity(0.5D); + full.getUnderground().getSeaInlets().setSpacing(256); + half.getUnderground().getSeaInlets().setSpacing(256); + int baseline = 0; + int retained = 0; + for (int offset = 0; offset < 32; offset++) { + InletTerrain shifted = terrain.shifted(0, offset * 512); + List all = shifted.plan(InletTerrain.settings(full)); + List reduced = shifted.plan(InletTerrain.settings(half)); + baseline += all.size(); + retained += reduced.size(); + for (SeaInlet inlet : reduced) { + assertTrue("retained inlets keep the same full passage", all.contains(inlet)); + } + assertEquals(reduced, shifted.plan(InletTerrain.settings(half))); + } + assertTrue("cliff provides enough candidates", baseline >= 32); + assertTrue("half density retains approximately half the sites", retained > baseline * 0.3 && retained < baseline * 0.7); + assertTrue(terrain.plan(InletTerrain.settings(new IrisHydrology().setDensity(0D))).isEmpty()); + } + @Test public void inletStartsAtOceanCell() { InletTerrain terrain = InletTerrain.cliff(30D); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java index e2e64a301..a26fe4c4c 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java @@ -10,15 +10,24 @@ import art.arcane.iris.generation.hydrology.grid.HydrologySampleBuffer; import art.arcane.iris.generation.hydrology.grid.HydrologyTerrainSource; import art.arcane.iris.generation.hydrology.grid.PriorityFlood; import art.arcane.iris.generation.hydrology.plan.HydrologySettings; +import art.arcane.iris.generation.hydrology.plan.IndexedWindow; +import art.arcane.iris.generation.hydrology.plan.RegionKey; +import art.arcane.iris.generation.hydrology.plan.RegionPlan; import art.arcane.iris.generation.hydrology.plan.WindowKey; +import art.arcane.iris.generation.hydrology.plan.WindowPlan; +import art.arcane.iris.generation.hydrology.raster.ChunkRasterizer; +import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster; +import art.arcane.iris.generation.hydrology.raster.ReachIndex; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.function.DoubleBinaryOperator; @@ -38,6 +47,77 @@ public class PondPlannerTest { private static final float BOWL_SPILL = 112f; private static final WindowKey ORIGIN = new WindowKey(0, 0); + @Test + public void smallSeededPondFollowsItsBowlAtBlockResolution() { + HydrologySettings settings = settings(config -> { + seededOnly(config); + config.getPonds().getSeeded().setDensity(1D).setRadius(new KList<>(8, 8)).setDepth(new KList<>(6, 6)) + .setAlongChannels(false); + config.getPonds().setFreeboard(new KList<>(1, 1)); + config.getPonds().getShore().setRoundness(0D); + }, MIN_Y, MAX_Y); + List ponds = plan(settings, new Terrain((x, z) -> 100D, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y), + 0, 0, 64, 64, new BitSet(), List.of()); + Pond pond = single(ponds); + PondMask mask = pond.mask(); + assertEquals(1, mask.step()); + int wet = 0; + int minX = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + for (int j = 0; j < mask.depth(); j++) { + for (int i = 0; i < mask.width(); i++) { + if (mask.node(i, j)) { + wet++; + minX = Math.min(minX, i); + minZ = Math.min(minZ, j); + maxX = Math.max(maxX, i); + maxZ = Math.max(maxZ, j); + } + } + } + int rectangle = (maxX - minX + 1) * (maxZ - minZ + 1); + assertTrue("rounded bowl occupies " + wet + " of " + rectangle + " bounding blocks", wet < rectangle * 0.9D); + assertFalse(mask.node(minX, minZ)); + assertFalse(mask.node(maxX, maxZ)); + assertTrue(mask.contains(pond.floorX(), pond.floorZ())); + } + + @Test + public void globalDensityHalvesSaturatedSeededSiteAcceptance() { + HydrologySettings full = settings(config -> { + seededOnly(config); + config.getPonds().getSeeded().setDensity(16D).setAlongChannels(false); + }, MIN_Y, MAX_Y); + HydrologySettings half = settings(config -> { + seededOnly(config); + config.setDensity(0.5D); + config.getPonds().getSeeded().setDensity(16D).setAlongChannels(false); + }, MIN_Y, MAX_Y); + Terrain terrain = new Terrain((x, z) -> 100D, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y); + Set all = seededSites(full, terrain); + Set reduced = seededSites(half, terrain); + assertTrue("full candidates " + all.size(), all.size() >= 450); + assertTrue(all.containsAll(reduced)); + double fraction = reduced.size() / (double) all.size(); + assertTrue("retained fraction " + fraction, fraction > 0.44D && fraction < 0.56D); + } + + private static Set seededSites(HydrologySettings settings, Terrain terrain) { + Set sites = new HashSet<>(); + for (int window = 0; window < 32; window++) { + int x = window * WindowKey.SIZE; + Sampled sampled = sample(terrain, 8, x, 0, 64, 64); + Arrays.fill(sampled.samples().pondDensity(), 100f); + CupSet cups = new PondPlanner(settings).subtractCups(sampled.grid(), new WindowKey(window, 0), sampled.samples(), new BitSet()); + for (CupSet.Cup cup : cups.within(x, 0, x + WindowKey.SIZE, WindowKey.SIZE)) { + sites.add(((long) cup.x() << 32) | (cup.z() & 0xFFFFFFFFL)); + } + } + return sites; + } + @Test public void naturalBasinPondSitsBelowSpill() { HydrologySettings settings = settings(PondPlannerTest::naturalOnly, MIN_Y, MAX_Y); @@ -187,6 +267,7 @@ public class PondPlannerTest { assertTrue(pond.mask().contains(200, 256)); assertTrue(pond.mask().contains(312, 256)); assertContained(pond, terrain); + assertRasterRimContained(settings, pond, terrain); } @Test @@ -527,6 +608,43 @@ public class PondPlannerTest { return new PondMask(x, 208, 16, 1, 7, new long[]{0b1111111L}); } + private static void assertRasterRimContained(HydrologySettings settings, Pond pond, DoubleBinaryOperator natural) { + Map rasters = new HashMap<>(); + ChunkRasterizer rasterizer = new ChunkRasterizer(settings); + int raised = 0; + int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + for (int[] wet : wetNodes(pond.mask())) { + for (int[] side : sides) { + int x = wet[0] + side[0]; + int z = wet[1] + side[1]; + if (pond.mask().contains(x, z)) { + continue; + } + long key = ((long) (x >> 4) << 32) | ((z >> 4) & 0xFFFFFFFFL); + ChunkWaterRaster raster = rasters.get(key); + if (raster == null) { + WindowPlan window = new WindowPlan(WindowKey.containing(x, z), List.of(), List.of(pond), List.of()); + IndexedWindow indexed = new IndexedWindow(window, ReachIndex.build(List.of(), List.of(pond))); + List regions = new ArrayList<>(); + for (RegionKey region : ChunkRasterizer.lakeRegions(x >> 4, z >> 4)) { + regions.add(new RegionPlan(region, List.of(), List.of())); + } + raster = rasterizer.rasterize(x >> 4, z >> 4, (px, pz) -> (int) Math.round(natural.applyAsDouble(px, pz)), + List.of(indexed), regions); + rasters.put(key, raster); + } + int ground = (int) Math.round(natural.applyAsDouble(x, z)); + int actual = raster.terrainHeight(x & 15, z & 15); + assertTrue("dry rim contains the water at " + x + "," + z, actual >= pond.level()); + assertTrue("rim fill remains bounded at " + x + "," + z, actual <= ground + settings.deformation().maxFill()); + if (actual > ground) { + raised++; + } + } + } + assertTrue("sub-lattice rim needs real raster berms", raised > 0); + } + private static Pond lake(PondMask mask) { return new Pond(42L, PondKind.LAKE, 110, 100, mask.originX(), mask.originZ(), 111f, mask, 0L, 0, "water", mask.originX(), mask.originZ()); } @@ -548,6 +666,7 @@ public class PondPlannerTest { private static void assertContained(Pond pond, DoubleBinaryOperator natural) { PondMask mask = pond.mask(); int step = mask.step(); + int maxFill = new IrisHydrology().getDeformation().getMaxFill(); List nodes = wetNodes(mask); assertFalse(nodes.isEmpty()); assertTrue("floor " + pond.floor() + " level " + pond.level(), pond.floor() < pond.level()); @@ -557,8 +676,8 @@ public class PondPlannerTest { int x = node[0] + direction[0]; int z = node[1] + direction[1]; if (!mask.contains(x, z)) { - float edge = (float) natural.applyAsDouble(x, z); - assertTrue("edge " + x + "," + z + " natural " + edge + " level " + pond.level(), edge >= pond.level()); + long edge = Math.round(natural.applyAsDouble(x, z)); + assertTrue("edge " + x + "," + z + " natural " + edge + " level " + pond.level(), edge + maxFill >= pond.level()); } } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java index a5ede8db5..9f8ed00e7 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java @@ -23,6 +23,10 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.studio.map.components.MapLayer; +import art.arcane.iris.studio.map.components.MapTile; +import art.arcane.iris.studio.map.components.MapTileRenderer; +import art.arcane.iris.studio.map.components.TileType; import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.testsupport.FakeHydrology; import art.arcane.iris.testsupport.PlatformLeakGuard; @@ -44,12 +48,14 @@ import java.util.EnumSet; import java.util.List; import java.util.Set; import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -208,6 +214,35 @@ public class IrisComplexHeightStreamTest { } } + @Test(timeout = 15_000L) + public void mapHeightPreviewReturnsWhilePlanningAndRefreshesToFinalTerrain() throws Exception { + try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) { + fixture.hydrology.hold(); + MapTileRenderer renderer = new MapTileRenderer(fixture.engine); + MapLayer layer = MapLayer.of(TileType.HEIGHT); + long before = ProvisionalSampling.revision(); + + MapTile preview = renderer.renderTile(0D, 0D, 1D, 16, layer, true, () -> false); + + assertEquals(HydrologyFixture.NATURAL, preview.heightAt(8, 8), 0D); + assertNotEquals(before, ProvisionalSampling.revision()); + fixture.hydrology.release(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10L); + MapTile settled; + do { + assertTrue("Map height preview never settled", System.nanoTime() < deadline); + Thread.sleep(10L); + before = ProvisionalSampling.revision(); + settled = renderer.renderTile(0D, 0D, 1D, 16, layer, true, () -> false); + } while (before != ProvisionalSampling.revision()); + + double terrain = fixture.complex.getHeightStream().getDouble(8D, 8D); + assertNotEquals(HydrologyFixture.NATURAL, terrain, 0D); + assertEquals(terrain, settled.heightAt(8, 8), 0D); + assertEquals(before, ProvisionalSampling.revision()); + } + } + @Test(timeout = 120_000L) public void waterStreamsFollowTheRaster() throws Exception { try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) { diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index db880ec5e..0e71c9267 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "dd1ea7f0361dfad24c4800e75f1e26e951b4f775aa1b541328724a8635afeeca"; + private static final String EXPECTED_SCHEMA_DIGEST = "6935e8d3914e5cde852522151d87fe898f79de3b394826e544c5c5bfbfceba57"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java b/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java index d04370d1b..8d38a26b1 100644 --- a/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java +++ b/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java @@ -13,6 +13,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BooleanSupplier; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -102,6 +104,42 @@ public class VisionRenderControllerTest { } } + @Test + public void zoomRetainsCompleteWaterTilesWhileDetailedTilesAreProvisional() throws Exception { + MapTileRenderer renderer = mock(MapTileRenderer.class); + MapTile overview = mock(MapTile.class); + MapTile incomplete = mock(MapTile.class); + when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) + .thenAnswer(invocation -> { + double scale = invocation.getArgument(2); + if (scale < 4D) { + ProvisionalSampling.mark(); + return incomplete; + } + return overview; + }); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false)); + await(() -> controller.progress(coarse).ready() == 1); + + VisionRenderController.Frame fine = controller.request(spec(renderer, 1L)); + await(() -> fine.tiles().getFirst().tile() != null); + VisionRenderController.VisibleTile visible = fine.tiles().getFirst(); + assertTrue(visible.provisional()); + assertEquals(0, controller.progress(fine).ready()); + VisionRenderController.Fallback retained = controller.fallback(fine, visible); + assertNotNull(retained); + assertSame(overview, retained.pieces().getFirst().tile()); + assertEquals(16, retained.pieces().getFirst().sourceSize()); + + VisionRenderController.Frame returned = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false)); + assertSame(overview, returned.tiles().getFirst().tile()); + assertFalse(returned.tiles().getFirst().provisional()); + } + } + @Test public void closeRejectsLatePublication() throws Exception { MapTileRenderer renderer = mock(MapTileRenderer.class); diff --git a/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java b/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java index 5401e6acc..689d43965 100644 --- a/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java +++ b/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java @@ -2,8 +2,10 @@ package art.arcane.iris.studio.render; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; +import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview; import art.arcane.iris.generation.hydrology.plan.HydrologySettings; import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType; +import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster; import art.arcane.iris.generation.hydrology.water.Pond; import art.arcane.iris.generation.hydrology.water.Reach; import art.arcane.iris.generation.runtime.Engine; @@ -93,6 +95,34 @@ public class IrisRendererRiverTest { } } + @Test + public void zoomKeepsResidentWaterPlansUntilDetailedRastersAreReady() { + HydrologyRuntime runtime = mock(HydrologyRuntime.class); + Pond pond = WaterScene.pond(32L, 62, 16, 16, 10); + when(runtime.enabled()).thenReturn(true); + when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(new HydrologyPlanPreview(List.of(), List.of(pond), List.of(), List.of(), true)); + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.PROVISIONAL); + IrisRenderer renderer = new IrisRenderer(engine(runtime)); + + BufferedImage overview = renderer.renderStudio(0D, 0D, 256D, 64, RenderType.RIVER, () -> false); + int water = IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.POND); + assertEquals(water, overview.getRGB(4, 4)); + + for (int step : new int[]{2, 1}) { + long before = ProvisionalSampling.revision(); + BufferedImage detailed = renderer.renderStudio(0D, 0D, step * 64D, 64, RenderType.RIVER, () -> false); + assertEquals(water, detailed.getRGB(16 / step, 16 / step)); + assertNotEquals(before, ProvisionalSampling.revision()); + } + + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY); + long before = ProvisionalSampling.revision(); + BufferedImage settled = renderer.renderStudio(0D, 0D, 64D, 64, RenderType.RIVER, () -> false); + assertEquals(NO_RIVER, settled.getRGB(16, 16)); + assertEquals(before, ProvisionalSampling.revision()); + } + @Test public void channelPixelsShowTheirTier() throws Exception { HydrologySettings settings = FakeHydrology.settings(SEA_LEVEL); diff --git a/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java b/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java index 95224f57a..97fa9eea3 100644 --- a/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java +++ b/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java @@ -6,8 +6,10 @@ import org.junit.Test; import javax.swing.SwingUtilities; import java.awt.Color; +import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; import java.awt.image.BufferedImage; import java.lang.reflect.Field; @@ -125,6 +127,82 @@ public class PregenMapPanelTest { SwingUtilities.invokeAndWait(extreme::disposeMap); } + @Test + public void wheelZoomPreservesTheChunkUnderThePointerAndRetainedPixels() throws Exception { + PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(-32, -32, 31, 31)); + SwingUtilities.invokeAndWait(() -> panel.setSize(802, 602)); + panel.submit(-7, -19, Color.GREEN); + flush(panel); + BufferedImage retained = image(panel); + String before = tooltip(panel, 337, 123); + SwingUtilities.invokeAndWait(() -> panel.dispatchEvent(new MouseWheelEvent(panel, + MouseEvent.MOUSE_WHEEL, 1L, 0, 337, 123, 0, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -4))); + assertEquals(before, tooltip(panel, 337, 123)); + assertTrue(panel.zoom() > 1D); + assertTrue(retained == image(panel)); + assertEquals(Color.GREEN.getRGB(), image(panel).getRGB(25, 13)); + SwingUtilities.invokeAndWait(panel::disposeMap); + } + + @Test + public void draggingPansTheMapAndResetFitsBesideTheSidebar() throws Exception { + PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(-32, -32, 31, 31)); + SwingUtilities.invokeAndWait(() -> { + panel.setSize(1000, 600); + panel.setCoveredLeftWidth(300); + }); + String initial = tooltip(panel, 650, 300); + assertNull(tooltip(panel, 250, 300)); + SwingUtilities.invokeAndWait(() -> { + panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, 1L, + MouseEvent.BUTTON1_DOWN_MASK, 650, 300, 1, false, MouseEvent.BUTTON1)); + panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_DRAGGED, 2L, + MouseEvent.BUTTON1_DOWN_MASK, 710, 350, 0, false, MouseEvent.NOBUTTON)); + panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, 3L, + 0, 710, 350, 1, false, MouseEvent.BUTTON1)); + }); + assertEquals(initial, tooltip(panel, 710, 350)); + assertFalse(initial.equals(tooltip(panel, 650, 300))); + SwingUtilities.invokeAndWait(() -> { + panel.zoomIn(); + panel.resetView(); + }); + assertEquals(1D, panel.zoom(), 0D); + assertEquals(initial, tooltip(panel, 650, 300)); + SwingUtilities.invokeAndWait(panel::disposeMap); + } + + @Test + public void zoomBoundsAndGridPaintingHandleExtremeMapCoordinates() throws Exception { + PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds( + Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)); + SwingUtilities.invokeAndWait(() -> { + panel.setSize(820, 520); + panel.toggleGrid(); + for (int index = 0; index < 100; index++) { + panel.zoomIn(); + } + assertEquals(128D, panel.zoom(), 0D); + BufferedImage target = new BufferedImage(820, 520, BufferedImage.TYPE_INT_RGB); + Graphics2D canvas = target.createGraphics(); + try { + panel.paint(canvas); + panel.panBy(1_000_000, -1_000_000); + panel.paint(canvas); + for (int index = 0; index < 100; index++) { + panel.zoomOut(); + } + assertEquals(0.25D, panel.zoom(), 0D); + panel.resetView(); + panel.paint(canvas); + } finally { + canvas.dispose(); + panel.disposeMap(); + } + }); + } + private static String tooltip(PregenMapPanel panel, int x, int y) throws Exception { String[] result = new String[1]; SwingUtilities.invokeAndWait(() -> result[0] = panel.getToolTipText( diff --git a/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java b/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java index d18db3c06..bc267ed20 100644 --- a/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java +++ b/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java @@ -11,8 +11,12 @@ import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JTextArea; +import javax.swing.JToggleButton; +import javax.swing.JViewport; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; +import java.awt.Component; +import java.awt.Container; import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import java.lang.reflect.Constructor; @@ -27,6 +31,50 @@ import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mockStatic; public class PregenRendererTest { + @Test + public void smallWindowOpensAtTheProgressHeaderWithoutScrollingAway() throws Exception { + PregenRenderer[] result = new PregenRenderer[1]; + onEdt(() -> { + result[0] = renderer(new Source(), () -> { }); + result[0].setSize(820, 520); + layout(result[0]); + }); + try { + onEdt(() -> layout(result[0])); + onEdt(() -> assertScrollAtTop(result[0])); + } finally { + onEdt(result[0]::close); + } + } + + @Test + public void mapShortcutsZoomPanResetAndKeepGridControlSynchronized() throws Exception { + onEdt(() -> { + PregenRenderer renderer = renderer(new Source(), () -> { }); + try { + PregenMapPanel map = field(renderer, "map", PregenMapPanel.class); + map.setSize(1000, 600); + map.setCoveredLeftWidth(300); + shortcut(renderer, KeyEvent.VK_EQUALS); + assertTrue(map.zoom() > 1D); + shortcut(renderer, KeyEvent.VK_LEFT); + shortcut(renderer, KeyEvent.VK_R); + assertEquals(1D, map.zoom(), 0D); + shortcut(renderer, KeyEvent.VK_G); + assertTrue(map.isGridVisible()); + JToggleButton grid = field(renderer, "gridToggle", JToggleButton.class); + assertTrue(grid.isSelected()); + grid.doClick(0); + assertFalse(map.isGridVisible()); + assertFalse(grid.isSelected()); + shortcut(renderer, KeyEvent.VK_SLASH); + shortcut(renderer, KeyEvent.VK_ESCAPE); + } finally { + renderer.close(); + } + }); + } + @Test public void keyboardAndButtonActivationToggleExactlyOnceAndTerminalControlsAreDisabled() throws Exception { onEdt(() -> { @@ -155,6 +203,32 @@ public class PregenRendererTest { refresh.invoke(renderer); } + private static void shortcut(PregenRenderer renderer, int keyCode) { + Object action = renderer.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + .get(KeyStroke.getKeyStroke(keyCode, 0)); + renderer.getActionMap().get(action).actionPerformed(new ActionEvent(renderer, 0, "shortcut")); + } + + private static void layout(Container container) { + container.doLayout(); + for (Component component : container.getComponents()) { + if (component instanceof Container child) { + layout(child); + } + } + } + + private static void assertScrollAtTop(Container container) { + for (Component component : container.getComponents()) { + if (component instanceof JViewport viewport) { + assertEquals(0, viewport.getViewPosition().y); + } + if (component instanceof Container child) { + assertScrollAtTop(child); + } + } + } + private static T field(PregenRenderer renderer, String name, Class type) throws Exception { Field field = PregenRenderer.class.getDeclaredField(name); field.setAccessible(true); diff --git a/core/src/test/java/art/arcane/iris/studio/workspace/IrisRiverSchemaTest.java b/core/src/test/java/art/arcane/iris/studio/workspace/IrisRiverSchemaTest.java index d7a6ea7fc..e16f189f6 100644 --- a/core/src/test/java/art/arcane/iris/studio/workspace/IrisRiverSchemaTest.java +++ b/core/src/test/java/art/arcane/iris/studio/workspace/IrisRiverSchemaTest.java @@ -68,6 +68,11 @@ public class IrisRiverSchemaTest { JSONObject fluid = referencedProperties(definitions, properties.getJSONObject("fluids").getJSONObject("items")); assertEquals("boolean", properties.getJSONObject("enabled").getString("type")); + JSONObject density = properties.getJSONObject("density"); + assertEquals("number", density.getString("type")); + assertEquals(0D, density.getDouble("minimum"), 0D); + assertEquals(1D, density.getDouble("maximum"), 0D); + assertTrue(density.getString("description").contains("Default Value is 1.0")); assertEquals("boolean", properties.getJSONObject("persist").getString("type")); assertEquals(0, properties.getJSONObject("prefetchRings").getInt("minimum")); assertEquals(4, properties.getJSONObject("prefetchRings").getInt("maximum")); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java index 8d24658c1..c2563791f 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java @@ -34,6 +34,8 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; @@ -895,7 +897,7 @@ public class GenerationRegistryContractFactoryTest { } @Test - public void rejectsAChangedPackBeforeCapture() throws Exception { + public void rejectsAPackChangedBeforeCapture() throws Exception { File pack = temporary.newFolder("changed-pack"); String original = GenerationPackFingerprint.compute( pack.toPath(), @@ -904,20 +906,58 @@ public class GenerationRegistryContractFactoryTest { Files.writeString(pack.toPath().resolve("dimension.json"), "{}", StandardCharsets.UTF_8); IrisData data = mock(IrisData.class); when(data.getDataFolder()).thenReturn(pack); + IrisDimension dimension = spy(new IrisDimension()); + dimension.setLoadKey("overworld"); + stubDimensions(data, dimension); + doReturn(new KList<>()).when(dimension).getReachableBiomes(any()); + doReturn(new KList<>()).when(dimension).getAllRegions(any()); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(new KList<>()); + when(registries.entityKeys()).thenReturn(new KList<>()); assertThrows( IOException.class, () -> GenerationRegistryContractFactory.create( data, - new IrisDimension(), + dimension, original, DataVersion.getLatest().get(), - mock(PlatformRegistries.class), + registries, new FakeGenerationRegistry("runtime") ) ); } + @Test + public void rejectsSameSizePackMutationDuringCaptureWithPreservedTimestamp() throws Exception { + File pack = temporary.newFolder("mutating-pack"); + Path content = pack.toPath().resolve("content.json"); + Files.writeString(content, "{\"version\":1}", StandardCharsets.UTF_8); + FileTime modified = Files.getLastModifiedTime(content); + String original = GenerationPackFingerprint.compute(pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + IrisDimension dimension = spy(new IrisDimension()); + dimension.setLoadKey("overworld"); + stubDimensions(data, dimension); + doReturn(new KList<>()).when(dimension).getReachableBiomes(any()); + doReturn(new KList<>()).when(dimension).getAllRegions(any()); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenAnswer(ignored -> { + Files.writeString(content, "{\"version\":2}", StandardCharsets.UTF_8); + Files.setLastModifiedTime(content, modified); + return new KList<>(); + }); + when(registries.entityKeys()).thenReturn(new KList<>()); + + IOException failure = assertThrows(IOException.class, () -> GenerationRegistryContractFactory.create( + data, dimension, original, DataVersion.getLatest().get(), registries, new FakeGenerationRegistry("runtime"))); + + assertEquals("Iris generation registry capture rejected a changed pack.", failure.getMessage()); + assertEquals(modified, Files.getLastModifiedTime(content)); + assertEquals("{\"version\":2}", Files.readString(content)); + } + @Test public void rejectsAnUnloadableAdditionalDimensionBeforeCapturingItsRegistry() throws Exception { File pack = temporary.newFolder("missing-secondary-dimension");