diff --git a/README.md b/README.md index 3edb78c6..94b2ffdc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Windows launcher: [world-downloader-launcher.exe](https://github.com/mircokroon/ Latest cross-platform jar (command-line support): [world-downloader.jar](https://github.com/mircokroon/minecraft-world-downloader/releases/latest/download/world-downloader.jar) ### Basic usage -[Download](https://github.com/mircokroon/minecraft-world-downloader/releases/latest/download/world-downloader.exe) the latest release and run it. Enter the server address in the address field and press start. +[Download](https://github.com/mircokroon/minecraft-world-downloader-launcher/releases/latest/download/world-downloader-launcher.exe) the latest release and run it. Enter the server address in the address field and press start. diff --git a/src/main/java/config/Config.java b/src/main/java/config/Config.java index 3286bdf0..ab939e3f 100644 --- a/src/main/java/config/Config.java +++ b/src/main/java/config/Config.java @@ -434,6 +434,10 @@ public static PacketInjector getPacketInjector() { usage = "Draw extended chunks to map") public boolean drawExtendedChunks = false; + @Option(name = "--enable-cave-mode", + usage = "Enable automatically switching to cave render mode when underground.") + public boolean enableCaveRenderMode = false; + // not really important enough to have an option for, can change it in config file public boolean smoothZooming = true; @@ -500,6 +504,9 @@ public static boolean smoothZooming() { public static boolean markOldChunks() { return instance.markOldChunks; } + public static boolean enableCaveRenderMode() { + return instance.enableCaveRenderMode; + } public static MicrosoftAuthHandler getMicrosoftAuth() { return instance.microsoftAuth; diff --git a/src/main/java/game/data/LevelData.java b/src/main/java/game/data/LevelData.java index d220c5c4..8d3c27f1 100644 --- a/src/main/java/game/data/LevelData.java +++ b/src/main/java/game/data/LevelData.java @@ -1,5 +1,7 @@ package game.data; +import static util.ExceptionHandling.attempt; + import config.Config; import config.Option; import config.Version; @@ -38,6 +40,7 @@ public LevelData(WorldManager worldManager) { this.outputDir = PathUtils.toPath(Config.getWorldOutputDir()); this.file = Paths.get(outputDir.toString(), "level.dat").toFile(); this.levelDataModifiers = new ArrayList<>(); + attempt(this::load); } public void registerModifier(Consumer fn) { diff --git a/src/main/java/game/data/WorldManager.java b/src/main/java/game/data/WorldManager.java index 2e606f8a..6fb0d9cf 100644 --- a/src/main/java/game/data/WorldManager.java +++ b/src/main/java/game/data/WorldManager.java @@ -76,7 +76,7 @@ public class WorldManager { private boolean writeChunks; private boolean isStarted; private boolean isPaused; - private Set savingDimension; + private final Set savingDimension; private ContainerManager containerManager; private CommandBlockManager commandBlockManager; @@ -85,7 +85,8 @@ public class WorldManager { private final RenderDistanceExtender renderDistanceExtender; private BiConsumer playerPosListener; - private CoordinateDouble3D playerPosition; + private final CoordinateDouble3D playerPosition; + private boolean isBelowGround = false; private double playerRotation = 0; private Dimension dimension; private final EntityRegistry entityRegistry; @@ -175,6 +176,25 @@ private void saveAndUnloadChunks() { this.regions = new ConcurrentHashMap<>(); } + private void checkAboveSurface() { + Coordinate3D discrete = this.playerPosition.discretize(); + + Coordinate3D local = discrete.globalToChunkLocal(); + + if (dimension == Dimension.NETHER) { + isBelowGround = local.getY() < 128; + return; + } + + Chunk c = getChunk(discrete.globalToChunk()); + if (c == null) { + return; + } + + int height = c.getChunkHeightHandler().heightAt(local.getX(), local.getZ()) - 5; + isBelowGround = local.getY() < height; + } + private void unloadChunks(Map regions) { regions.values().forEach(Region::unloadAll); } @@ -398,6 +418,8 @@ public void start() { } private void save(Dimension dimension, Map regions) { + checkAboveSurface(); + if (!writeChunks) { return; } @@ -721,5 +743,9 @@ public boolean canForget(CoordinateDim2D co) { public int countExtendedChunks() { return renderDistanceExtender.countLoaded(); } + + public boolean isBelowGround() { + return isBelowGround; + } } diff --git a/src/main/java/game/data/chunk/Cave.java b/src/main/java/game/data/chunk/Cave.java new file mode 100644 index 00000000..74fb952e --- /dev/null +++ b/src/main/java/game/data/chunk/Cave.java @@ -0,0 +1,71 @@ +package game.data.chunk; + +import game.data.chunk.palette.BlockState; +import game.data.chunk.palette.SimpleColor; +import java.util.Objects; + +/** + * Cave class used in image rendering + */ +public class Cave { + int y; + int depth; + BlockState block; + + Cave(int y, BlockState block) { + this.y = y; + this.block = block; + this.depth = 1; + } + + public void addDepth() { + depth += 1; + } + + public int y() { return y; } + + public int depth() { return depth; } + + public BlockState block() { return block; } + + @Override + public boolean equals(Object obj) { + if (obj == this) { return true; } + if (obj == null || obj.getClass() != this.getClass()) { return false; } + var that = (Cave) obj; + return this.y == that.y && + this.depth == that.depth && + Objects.equals(this.block, that.block); + } + + @Override + public int hashCode() { + return Objects.hash(y, depth, block); + } + + @Override + public String toString() { + return "Cave[" + + "y=" + y + ", " + + "depth=" + depth + ", " + + "block=" + block + ']'; + } + + /** + * Adjust colour based on height and depth of cave + */ + public SimpleColor getColor() { + double brightness = 230 * (0.05 + (Math.log(depth) / Math.log(80)) * 0.9); + SimpleColor caveDepth = new SimpleColor(10, brightness / 2, brightness) + .blendWith(new SimpleColor(brightness, 10, 10), map(-80, 100, y)); + + SimpleColor blockCol = block.getColor(); + return caveDepth.blendWith(blockCol, .85); + } + + private double map(double min, double max, double val) { + if (val < min) { return 0; } + if (val > max) { return 1; } + return (val - min) / (max - min); + } +} diff --git a/src/main/java/game/data/chunk/Chunk.java b/src/main/java/game/data/chunk/Chunk.java index 9c3d7702..12bd0683 100644 --- a/src/main/java/game/data/chunk/Chunk.java +++ b/src/main/java/game/data/chunk/Chunk.java @@ -13,6 +13,7 @@ import game.protocol.Protocol; import java.util.function.BiConsumer; import java.util.function.Consumer; +import org.apache.commons.lang3.mutable.MutableBoolean; import packets.DataTypeProvider; import packets.builder.PacketBuilder; import se.llbit.nbt.*; @@ -40,6 +41,12 @@ public static void registerNbtModifier(BiConsumer fn) { private boolean saved; private ChunkImageFactory imageFactory; + public ChunkHeightHandler getChunkHeightHandler() { + return chunkHeightHandler; + } + + private ChunkHeightHandler chunkHeightHandler; + private final int dataVersion; public Chunk(CoordinateDim2D location, int dataVersion) { @@ -480,6 +487,8 @@ public void unload() { public ChunkImageFactory getChunkImageFactory() { if (imageFactory == null) { + chunkHeightHandler = new ChunkHeightHandler(this); + // assignment should happen before running initialisation code imageFactory = new ChunkImageFactory(this); imageFactory.initialise(); @@ -518,7 +527,8 @@ public void updateBlock(Coordinate3D coords, int blockStateId, boolean suppressU } if (this.imageFactory != null) { - this.imageFactory.updateHeight(coords); + this.chunkHeightHandler.updateHeight(coords); + this.imageFactory.generateImages(); } } @@ -544,7 +554,10 @@ public void updateBlocks(Coordinate3D pos, DataTypeProvider provider) { updateBlock(blockPos, blockId, true); } - this.getChunkImageFactory().recomputeHeights(toUpdate); + boolean wasChanged = this.chunkHeightHandler.recomputeHeights(toUpdate); + if (wasChanged) { + imageFactory.generateImages(); + } } public void updateLight(DataTypeProvider provider) { diff --git a/src/main/java/game/data/chunk/ChunkHeightHandler.java b/src/main/java/game/data/chunk/ChunkHeightHandler.java new file mode 100644 index 00000000..4eeb9f58 --- /dev/null +++ b/src/main/java/game/data/chunk/ChunkHeightHandler.java @@ -0,0 +1,155 @@ +package game.data.chunk; + +import game.data.chunk.palette.BlockState; +import game.data.chunk.palette.SimpleColor; +import game.data.coordinates.Coordinate3D; +import game.data.dimension.Dimension; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.apache.commons.lang3.mutable.MutableBoolean; + +public class ChunkHeightHandler { + private int[] heightMap; + private int[] heightMapBelowBedrock; + private List[] caves; + Chunk c; + + public ChunkHeightHandler(Chunk c) { + this.c = c; + + this.computeHeightMap(); + } + + private void computeHeightMap() { + if (this.heightMap != null) { + return; + } + + this.heightMapBelowBedrock = new int[Chunk.SECTION_WIDTH * Chunk.SECTION_WIDTH]; + this.heightMap = new int[Chunk.SECTION_WIDTH * Chunk.SECTION_WIDTH]; + this.caves = new List[Chunk.SECTION_WIDTH * Chunk.SECTION_WIDTH]; + for (int x = 0; x < Chunk.SECTION_WIDTH; x++) { + for (int z = 0; z < Chunk.SECTION_WIDTH; z++) { + heightMapBelowBedrock[z << 4 | x] = computeHeight(x, z, true); + heightMap[z << 4 | x] = computeHeight(x, z, false); + caves[z << 4 | x] = findCaves(x, z); + } + } + } + + /** + * Computes the height at a given location. When we are in the nether, we want to try and make it clear where there + * is an opening, and where there is not. For this we skip the first two chunks sections (these will be mostly solid + * anyway, but may contain misleading caves). We then only count blocks after we've found some air space. + */ + private int computeHeight(int x, int z, boolean ignoredBedrockAbove) { + // if we're in the Nether, we want to find an air block before we start counting blocks. + boolean isNether = ignoredBedrockAbove && c.location.getDimension().equals(Dimension.NETHER); + int topSection = isNether ? 5 : c.getMaxBlockSection(); + + MutableBoolean foundAir = new MutableBoolean(!isNether); + + for (int sectionY = topSection; sectionY >= c.getMinBlockSection(); sectionY--) { + ChunkSection cs = c.getChunkSection(sectionY); + if (cs == null) { + foundAir.setTrue(); + continue; + } + + int height = cs.computeHeight(x, z, foundAir); + + if (height < 0) { continue; } + + // if we're in the nether we can't find + if (isNether && sectionY == topSection && height == 15) { + return 127; + } + return (sectionY * Chunk.SECTION_HEIGHT) + height; + } + return isNether ? 127 : 0; + } + + /** + * We need to update the image only if the updated block was either the top layer, or above the top layer. + * Technically this does not take transparent blocks into account, but that's fine. + */ + public boolean updateHeight(Coordinate3D coords) { + if (coords.getY() >= heightAt(coords.getX(), coords.getZ())) { + recomputeHeight(coords.getX(), coords.getZ()); + + return true; + } + return false; + } + + /** + * Recompute the heights in the given coordinate collection. We keep track of which heights actually changed, and + * only redraw if we have to. + */ + public boolean recomputeHeights(Collection toUpdate) { + boolean hasChanged = false; + for (Coordinate3D pos : toUpdate) { + if (pos.getY() >= heightAt(pos.getX(), pos.getZ())) { + hasChanged |= recomputeHeight(pos.getX(), pos.getZ()); + } + } + return hasChanged; + } + + private boolean recomputeHeight(int x, int z) { + int beforeAboveBedrock = heightMapBelowBedrock[z << 4 | x]; + int before = heightMap[z << 4 | x]; + + int afterAboveBedrock = computeHeight(x, z, true); + heightMapBelowBedrock[z << 4 | x] = afterAboveBedrock; + + int after = computeHeight(x, z, false); + heightMap[z << 4 | x] = after; + caves[z << 4 | x] = getCaves(x, z); + + return before != after || beforeAboveBedrock != afterAboveBedrock; + } + + public int heightAt(int x, int z) { + return heightAt(x, z, false); + } + + public int heightAt(int x, int z, boolean belowBedrock) { + return belowBedrock ? heightMapBelowBedrock[z << 4 | x] : heightMap[z << 4 | x]; + } + + private List findCaves(int x, int z) { + int surface = heightAt(x, z); + surface = Math.min(60, surface); + + List caves = new ArrayList<>(); + + int base = c.getMinBlockSection() * Chunk.SECTION_HEIGHT; + BlockState state = null; + + Cave cave = null; + boolean inCave = false; + for (int y = base; y < surface; y++) { + BlockState curState = c.getBlockStateAt(x, y, z); + + boolean isEmpty = curState == null || curState.getColor() == SimpleColor.BLACK; + if (inCave && isEmpty) { + cave.addDepth(); + } else if (inCave) { + inCave = false; + } else if (isEmpty && state != null) { + cave = new Cave(y, state); + caves.add(cave); + inCave = true; + } + state = curState; + } + + return caves; + } + + public List getCaves(int x, int z) { + return caves[z << 4 | x]; + } +} diff --git a/src/main/java/game/data/chunk/ChunkImageFactory.java b/src/main/java/game/data/chunk/ChunkImageFactory.java index c968c466..8ec3891d 100644 --- a/src/main/java/game/data/chunk/ChunkImageFactory.java +++ b/src/main/java/game/data/chunk/ChunkImageFactory.java @@ -1,323 +1,278 @@ -package game.data.chunk; - -import game.data.WorldManager; -import game.data.chunk.palette.BlockState; -import game.data.chunk.palette.SimpleColor; -import game.data.chunk.palette.blending.IBlendEquation; -import game.data.coordinates.Coordinate3D; -import game.data.coordinates.CoordinateDim2D; -import game.data.dimension.Dimension; -import java.nio.IntBuffer; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.function.BiConsumer; -import javafx.scene.image.Image; -import javafx.scene.image.WritableImage; -import javafx.scene.image.WritablePixelFormat; -import org.apache.commons.lang3.mutable.MutableBoolean; - -/** - * Handles creating images from a Chunk. - */ -public class ChunkImageFactory { - private final List registeredCallbacks = new ArrayList<>(2); - private final Runnable requestImage = this::requestImage;; - private BiConsumer onImageDone; - private Runnable onSaved; - - private final Chunk c; - private Chunk south; - private Chunk north; - - private int[] heightMap; - - private boolean drawnBefore = false; - - public ChunkImageFactory(Chunk c) { - this.c = c; - - c.setOnUnload(this::unload); - } - - /** - * Since image factories will call upon image factories of neighbouring chunks, we need to make - * sure it can be assigned first so that we don't end up making duplicates and causing memory - * leaks. Initialise should be called immediately after assignment. - */ - public void initialise() { - computeHeightMap(); - WorldManager.getInstance().chunkLoadedCallback(c); - } - - /** - * Set handler for when the image has been created. - */ - public void onComplete(BiConsumer onComplete) { - this.onImageDone = onComplete; - } - - public void onSaved(Runnable onSaved) { - this.onSaved = onSaved; - } - - public void markSaved() { - if (this.onSaved != null) { - this.onSaved.run(); - } - } - - private void registerChunkLoadCallback(CoordinateDim2D coordinates) { - registeredCallbacks.add(coordinates); - WorldManager.getInstance().registerChunkLoadCallback(coordinates, requestImage); - } - - public void unload() { - for (CoordinateDim2D coords : registeredCallbacks) { - WorldManager.getInstance().deregisterChunkLoadCallback(coords, requestImage); - } - } - - public int heightAt(int x, int z) { - return heightMap[z << 4 | x]; - } - - /** - * Compares the blocks south and north, use the gradient to get a multiplier for the colour. - * @return a colour multiplier to adjust the color value by. If they elevations are the same it will be 1.0, if the - * northern block is above the current its 0.8, otherwise its 1.2. - */ - private double getColorShader(int x, int y, int z) { - int yNorth = getOtherHeight(x, z, 0, -1, north); - if (yNorth < 0) { yNorth = y; } - - int ySouth = getOtherHeight(x, z, 15, 1, south); - if (ySouth < 0) { ySouth = y; } - - if (ySouth < yNorth) { - return 0.6 + (0.4 / (1 + yNorth - ySouth)); - } else if (ySouth > yNorth) { - return 1.6 - (0.6 / Math.sqrt(ySouth - yNorth)); - } - return 1; - } - - /** - * Get the height of a neighbouring block. If the block is not on this chunk, either load it or register a callback - * for when it becomes available. - */ - private int getOtherHeight(int x, int z, int zLimit, int offsetZ, Chunk other) { - if (z != zLimit) { - return heightAt(x, z + offsetZ); - } - - if (other == null) { - return -1; - } else { - return other.getChunkImageFactory().heightAt(x, 15 - zLimit); - } - } - - - public void requestImage() { - // this method is only called either the first time by the UI, or subsequently by callbacks - // when adjacent chunks load in. This means that if we only had a single callback registered - // we don't need to worry about de-registering it anymore. - if (drawnBefore && registeredCallbacks.size() == 1) { - registeredCallbacks.clear(); - } - - createImage(); - } - /** - * Generate and return the overview image for this chunk. - */ - private void createImage() { - WritableImage i = new WritableImage(Chunk.SECTION_WIDTH, Chunk.SECTION_WIDTH); - int[] output = new int[Chunk.SECTION_WIDTH * Chunk.SECTION_WIDTH]; - WritablePixelFormat format = WritablePixelFormat.getIntArgbInstance(); - - // setup north/south chunks - setupAdjacentChunks(); - drawnBefore = true; - - try { - for (int x = 0; x < Chunk.SECTION_WIDTH; x++) { - for (int z = 0; z < Chunk.SECTION_WIDTH; z++) { - int y = heightAt(x, z); - BlockState blockState = c.getBlockStateAt(x, y, z); - - SimpleColor color; - if (blockState == null) { - output[x + Chunk.SECTION_WIDTH * z] = new SimpleColor(0).toARGB(); - continue; - } else { - color = shadeTransparent(blockState, x, y, z); - } - - color = color.shaderMultiply(getColorShader(x, y, z)); - - // mark new chunks in a red-ish outline - if (c.isNewChunk() && ((x == 0 || x == 15) || (z == 0 || z == 15))) { - color = color.highlight(); - } - - output[x + Chunk.SECTION_WIDTH * z] = color.toARGB(); - } - } - i.getPixelWriter().setPixels( - 0, 0, - Chunk.SECTION_WIDTH, Chunk.SECTION_WIDTH, - format, output, 0, Chunk.SECTION_WIDTH - ); - } catch (Exception ex) { - System.out.println("Unable to draw picture for chunk at " + c.location); - ex.printStackTrace(); - clearAdjacentChunks(); - } - - if (this.onImageDone != null) { - this.onImageDone.accept(i, c.isSaved()); - } - clearAdjacentChunks(); - } - - /** - * Clear references to north/south chunks after drawing. If we don't do this we may keep the - * chunks from getting GCd causing memory leaks (especially when moving long distances in north/ - * south direction) - */ - private void clearAdjacentChunks() { - this.north = null; - this.south = null; - } - - private void setupAdjacentChunks() { - CoordinateDim2D coordinateSouth = c.location.addWithDimension(0, 1); - this.south = WorldManager.getInstance().getChunk(coordinateSouth); - - CoordinateDim2D coordinateNorth = c.location.addWithDimension(0, -1); - this.north = WorldManager.getInstance().getChunk(coordinateNorth); - - if (!drawnBefore) { - if (this.south == null) { - registerChunkLoadCallback(coordinateSouth); - } - if (this.north == null) { - registerChunkLoadCallback(coordinateNorth); - } - } - } - - private SimpleColor shadeTransparent(BlockState blockState, int x, int y, int z) { - SimpleColor color = blockState.getColor(); - BlockState next; - for (int level = y - 1; blockState.isTransparent() && level >= 0; level--) { - next = c.getBlockStateAt(x, level, z); - - if (next == blockState) { - continue; - } else if (next == null) { - break; - } - - IBlendEquation equation = blockState.getTransparencyEquation(); - double ratio = equation.getRatio(y - level); - color = color.blendWith(next.getColor(), ratio); - - // stop once the contribution to the colour is less than 10% - if (ratio > 0.90) { - break; - } - blockState = next; - } - return color; - } - - private void computeHeightMap() { - if (this.heightMap != null) { - return; - } - - this.heightMap = new int[Chunk.SECTION_WIDTH * Chunk.SECTION_WIDTH]; - for (int x = 0; x < Chunk.SECTION_WIDTH; x++) { - for (int z = 0; z < Chunk.SECTION_WIDTH; z++) { - heightMap[z << 4 | x] = computeHeight(x, z); - } - } - } - - /** - * Computes the height at a given location. When we are in the nether, we want to try and make it clear where there - * is an opening, and where there is not. For this we skip the first two chunks sections (these will be mostly solid - * anyway, but may contain misleading caves). We then only count blocks after we've found some air space. - */ - private int computeHeight(int x, int z) { - // if we're in the Nether, we want to find an air block before we start counting blocks. - boolean isNether = c.location.getDimension().equals(Dimension.NETHER); - int topSection = isNether ? 5 : c.getMaxBlockSection(); - - MutableBoolean foundAir = new MutableBoolean(!isNether); - - for (int sectionY = topSection; sectionY >= c.getMinBlockSection(); sectionY--) { - ChunkSection cs = c.getChunkSection(sectionY); - if (cs == null) { - foundAir.setTrue(); - continue; - } - - int height = cs.computeHeight(x, z, foundAir); - - if (height < 0) { continue; } - - // if we're in the nether we can't find - if (isNether && sectionY == topSection && height == 15) { - return 127; - } - return (sectionY * Chunk.SECTION_HEIGHT) + height; - } - return isNether ? 127 : 0; - } - - /** - * We need to update the image only if the updated block was either the top layer, or above the top layer. - * Technically this does not take transparent blocks into account, but that's fine. - */ - public void updateHeight(Coordinate3D coords) { - if (coords.getY() >= heightAt(coords.getX(), coords.getZ())) { - recomputeHeight(coords.getX(), coords.getZ()); - createImage(); - } - } - - /** - * Recompute the heights in the given coordinate collection. We keep track of which heights actually changed, and - * only redraw if we have to. - */ - public void recomputeHeights(Collection toUpdate) { - boolean hasChanged = false; - for (Coordinate3D pos : toUpdate) { - if (pos.getY() >= heightAt(pos.getX(), pos.getZ())) { - hasChanged |= recomputeHeight(pos.getX(), pos.getZ()); - } - } - if (hasChanged) { - createImage(); - } - } - - private boolean recomputeHeight(int x, int z) { - int before = heightMap[z << 4 | x]; - int after = computeHeight(x, z); - heightMap[z << 4 | x] = after; - - return before != after; - } - - @Override - public String toString() { - return "ChunkImageFactory{" + - "c=" + c.location + - '}'; - } -} +package game.data.chunk; + +import game.data.WorldManager; +import game.data.chunk.palette.BlockState; +import game.data.chunk.palette.SimpleColor; +import game.data.chunk.palette.blending.IBlendEquation; +import game.data.coordinates.CoordinateDim2D; +import gui.images.ImageMode; +import java.nio.IntBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import javafx.scene.image.Image; +import javafx.scene.image.WritableImage; +import javafx.scene.image.WritablePixelFormat; + +/** + * Handles creating images from a Chunk. + */ +public class ChunkImageFactory { + private final List registeredCallbacks = new ArrayList<>(2); + private final Runnable requestImage = this::requestImage; + ; + private BiConsumer, Boolean> onImageDone; + private Runnable onSaved; + + private final Chunk c; + private Chunk south; + private Chunk north; + + private boolean drawnBefore = false; + + public ChunkImageFactory(Chunk c) { + this.c = c; + + c.setOnUnload(this::unload); + } + + /** + * Since image factories will call upon image factories of neighbouring chunks, we need to make + * sure it can be assigned first so that we don't end up making duplicates and causing memory + * leaks. Initialise should be called immediately after assignment. + */ + public void initialise() { + WorldManager.getInstance().chunkLoadedCallback(c); + } + + /** + * Set handler for when the image has been created. + */ + public void onComplete(BiConsumer, Boolean> onComplete) { + this.onImageDone = onComplete; + } + + public void onSaved(Runnable onSaved) { + this.onSaved = onSaved; + } + + public void markSaved() { + if (this.onSaved != null) { + this.onSaved.run(); + } + } + + private void registerChunkLoadCallback(CoordinateDim2D coordinates) { + registeredCallbacks.add(coordinates); + WorldManager.getInstance().registerChunkLoadCallback(coordinates, requestImage); + } + + public void unload() { + for (CoordinateDim2D coords : registeredCallbacks) { + WorldManager.getInstance().deregisterChunkLoadCallback(coords, requestImage); + } + } + + /** + * Compares the blocks south and north, use the gradient to get a multiplier for the colour. + * + * @return a colour multiplier to adjust the color value by. If they elevations are the same it will be 1.0, if the + * northern block is above the current its 0.8, otherwise its 1.2. + */ + private double getColorShader(int x, int y, int z, boolean ignoreBedrock) { + int yNorth = getOtherHeight(x, z, 0, -1, north, ignoreBedrock); + if (yNorth < 0) { yNorth = y; } + + int ySouth = getOtherHeight(x, z, 15, 1, south, ignoreBedrock); + if (ySouth < 0) { ySouth = y; } + + if (ySouth < yNorth) { + return 0.6 + (0.4 / (1 + yNorth - ySouth)); + } else if (ySouth > yNorth) { + return 1.6 - (0.6 / Math.sqrt(ySouth - yNorth)); + } + return 1; + } + + /** + * Get the height of a neighbouring block. If the block is not on this chunk, either load it or register a callback + * for when it becomes available. + */ + private int getOtherHeight(int x, int z, int zLimit, int offsetZ, Chunk other, boolean ignoredBedrock) { + if (z != zLimit) { + return heightAt(x, z + offsetZ, ignoredBedrock); + } + + if (other == null || other.getChunkHeightHandler() == null) { + return -1; + } else { + return other.getChunkHeightHandler().heightAt(x, 15 - zLimit, ignoredBedrock); + } + } + + + public void requestImage() { + // this method is only called either the first time by the UI, or subsequently by callbacks + // when adjacent chunks load in. This means that if we only had a single callback registered + // we don't need to worry about de-registering it anymore. + if (drawnBefore && registeredCallbacks.size() == 1) { + registeredCallbacks.clear(); + } + + generateImages(); + } + + private SimpleColor getColorCave(int x, int z) { + List caves = c.getChunkHeightHandler().getCaves(x, z); + + if (caves.isEmpty()) { + return new SimpleColor(0); + } + + SimpleColor c = caves.get(0).getColor(); + + for (int i = 1; i < caves.size(); i++) { + SimpleColor next = caves.get(i).getColor(); + + c = c.blendWith(next, 1.0 / (i + 1)); + } + + return c; + } + + private SimpleColor getColorSurface(int x, int z, boolean useIgnoredBedrock) { + int y = heightAt(x, z, useIgnoredBedrock); + BlockState blockState = c.getBlockStateAt(x, y, z); + + if (blockState == null) { + return new SimpleColor(0); + } + + SimpleColor color = shadeTransparent(blockState, x, y, z); + + color = color.shaderMultiply(getColorShader(x, y, z, useIgnoredBedrock)); + + // mark new chunks in a red-ish outline + if (c.isNewChunk() && ((x == 0 || x == 15) || (z == 0 || z == 15))) { + color = color.highlight(); + } + + return color; + } + + + private Image createImage(boolean isSurface) { + WritableImage i = new WritableImage(Chunk.SECTION_WIDTH, Chunk.SECTION_WIDTH); + int[] output = new int[Chunk.SECTION_WIDTH * Chunk.SECTION_WIDTH]; + WritablePixelFormat format = WritablePixelFormat.getIntArgbInstance(); + + // setup north/south chunks + setupAdjacentChunks(); + drawnBefore = true; + + boolean isNether = c.getDimension().isNether(); + try { + for (int x = 0; x < Chunk.SECTION_WIDTH; x++) { + for (int z = 0; z < Chunk.SECTION_WIDTH; z++) { + + SimpleColor color = isSurface ? getColorSurface(x, z, false) : isNether ? getColorSurface(x, z, true) : getColorCave(x, z); + + output[x + Chunk.SECTION_WIDTH * z] = color.toARGB(); + } + } + i.getPixelWriter().setPixels( + 0, 0, + Chunk.SECTION_WIDTH, Chunk.SECTION_WIDTH, + format, output, 0, Chunk.SECTION_WIDTH + ); + } catch (Exception ex) { + System.out.println("Unable to draw picture for chunk at " + c.location); + ex.printStackTrace(); + clearAdjacentChunks(); + } + return i; + } + + + + + /** + * Generate and return the overview image for this chunk. + */ + void generateImages() { + if (this.onImageDone != null) { + Map map = Map.of( + ImageMode.NORMAL, createImage(true), + ImageMode.CAVES, createImage(false) + ); + this.onImageDone.accept(map, c.isSaved()); + } + clearAdjacentChunks(); + } + + /** + * Clear references to north/south chunks after drawing. If we don't do this we may keep the + * chunks from getting GCd causing memory leaks (especially when moving long distances in north/ + * south direction) + */ + private void clearAdjacentChunks() { + this.north = null; + this.south = null; + } + + private void setupAdjacentChunks() { + CoordinateDim2D coordinateSouth = c.location.addWithDimension(0, 1); + this.south = WorldManager.getInstance().getChunk(coordinateSouth); + + CoordinateDim2D coordinateNorth = c.location.addWithDimension(0, -1); + this.north = WorldManager.getInstance().getChunk(coordinateNorth); + + if (!drawnBefore) { + if (this.south == null) { + registerChunkLoadCallback(coordinateSouth); + } + if (this.north == null) { + registerChunkLoadCallback(coordinateNorth); + } + } + } + + private SimpleColor shadeTransparent(BlockState blockState, int x, int y, int z) { + SimpleColor color = blockState.getColor(); + BlockState next; + for (int level = y - 1; blockState.isTransparent() && level >= 0; level--) { + next = c.getBlockStateAt(x, level, z); + + if (next == blockState) { + continue; + } else if (next == null) { + break; + } + + IBlendEquation equation = blockState.getTransparencyEquation(); + double ratio = equation.getRatio(y - level); + color = color.blendWith(next.getColor(), ratio); + + // stop once the contribution to the colour is less than 10% + if (ratio > 0.90) { + break; + } + blockState = next; + } + return color; + } + + private int heightAt(int x, int z, boolean ignoreBedrock) { + return c.getChunkHeightHandler().heightAt(x, z, ignoreBedrock); + } + + @Override + public String toString() { + return "ChunkImageFactory{" + + "c=" + c.location + + '}'; + } +} + + diff --git a/src/main/java/game/data/chunk/palette/SimpleColor.java b/src/main/java/game/data/chunk/palette/SimpleColor.java index 54691baa..2f3a3b12 100644 --- a/src/main/java/game/data/chunk/palette/SimpleColor.java +++ b/src/main/java/game/data/chunk/palette/SimpleColor.java @@ -19,12 +19,16 @@ public SimpleColor(int full) { this.b = full & 0xFF; } + public SimpleColor(double shade) { + this(0xFF * shade, 0xFF * shade, 0xFF * shade); + } + private SimpleColor(int r, int g, int b) { this.r = r; this.g = g; this.b = b; } - private SimpleColor(double r, double g, double b) { + public SimpleColor(double r, double g, double b) { this.r = r; this.g = g; this.b = b; diff --git a/src/main/java/game/data/chunk/version/Chunk_1_16.java b/src/main/java/game/data/chunk/version/Chunk_1_16.java index 8bd30282..1c235035 100644 --- a/src/main/java/game/data/chunk/version/Chunk_1_16.java +++ b/src/main/java/game/data/chunk/version/Chunk_1_16.java @@ -63,7 +63,7 @@ public void updateBlocks(Coordinate3D pos, DataTypeProvider provider) { updateBlock(blockPos, blockId, true); } - this.getChunkImageFactory().recomputeHeights(toUpdate); + this.getChunkHeightHandler().recomputeHeights(toUpdate); } @Override diff --git a/src/main/java/game/data/coordinates/Coordinate2D.java b/src/main/java/game/data/coordinates/Coordinate2D.java index f124a9dc..915cf532 100644 --- a/src/main/java/game/data/coordinates/Coordinate2D.java +++ b/src/main/java/game/data/coordinates/Coordinate2D.java @@ -41,6 +41,13 @@ public Coordinate2D offsetRegionToActual() { ); } + public int distance(Coordinate2D other) { + long diffX = this.x - other.x; + long diffZ = this.z - other.z; + + return (int) Math.sqrt(diffX * diffX + diffZ * diffZ); + } + public boolean isInRangeChebyshev(Coordinate2D other, int distance) { return Math.abs(this.x - other.x) + Math.abs(this.z - other.z) <= distance; } @@ -131,8 +138,8 @@ public CoordinateDim2D addDimension(Dimension dimension) { return new CoordinateDim2D(this, dimension); } - public int blockDistance(Coordinate2D globalToChunk) { - return Math.max(Math.abs(this.x - globalToChunk.x), Math.abs(this.z - globalToChunk.z)); + public int blockDistance(Coordinate2D other) { + return Math.max(Math.abs(this.x - other.x), Math.abs(this.z - other.z)); } public Coordinate2D divide(int size) { diff --git a/src/main/java/game/data/dimension/Dimension.java b/src/main/java/game/data/dimension/Dimension.java index 1bfcad8d..d2266697 100644 --- a/src/main/java/game/data/dimension/Dimension.java +++ b/src/main/java/game/data/dimension/Dimension.java @@ -183,6 +183,10 @@ public void setType(String dimensionType) { // re-write since we write the dimension information on join otherwise attempt(() -> write(PathUtils.toPath(Config.getWorldOutputDir(), "datapacks", "downloaded", "data"))); } + + public boolean isNether() { + return this == NETHER; + } } /** diff --git a/src/main/java/gui/GuiMap.java b/src/main/java/gui/GuiMap.java index c736b46e..e61446dc 100644 --- a/src/main/java/gui/GuiMap.java +++ b/src/main/java/gui/GuiMap.java @@ -10,6 +10,9 @@ import game.data.chunk.Chunk; import game.data.dimension.Dimension; import game.data.entity.PlayerEntity; +import gui.images.RegionImageHandler; +import gui.markers.MapMarkerHandler; +import gui.markers.PlayerMarker; import javafx.animation.AnimationTimer; import javafx.application.Platform; import javafx.beans.property.ReadOnlyDoubleProperty; @@ -24,14 +27,13 @@ import javafx.scene.input.MouseButton; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; +import javafx.scene.shape.FillRule; import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import javafx.scene.text.TextAlignment; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.nio.IntBuffer; import java.util.Collection; +import util.PrintUtils; /** * Controller for the map scene. Contains a canvas for chunks which is redrawn only when required, and one for entities @@ -40,6 +42,7 @@ public class GuiMap { private static final Color BACKGROUND_COLOR = new Color(.16, .16, .16, 1); private static final Color PLAYER_COLOR = new Color(.6, .95, 1, .7); + private static final Color PLAYER_STROKE = Color.color(.1f, .1f, .1f); public Canvas chunkCanvas; public Canvas entityCanvas; @@ -49,19 +52,21 @@ public class GuiMap { public Label statusLabel; private CoordinateDouble3D playerPos; + private Coordinate2D cursorPos; private double playerRotation; + private double targetBlocksPerPixel; private double blocksPerPixel; private int gridSize; private RegionImageHandler regionHandler; + private MapMarkerHandler markerHandler; private Collection otherPlayers; ReadOnlyDoubleProperty width; ReadOnlyDoubleProperty height; private boolean mouseOver = false; - private boolean enableModernImageHandling = true; private boolean playerHasConnected = false; private boolean showErrorPrompt = false; private boolean lockedToPlayer = true; @@ -76,10 +81,13 @@ public class GuiMap { private ZoomBehaviour zoomBehaviour; + private final PlayerMarker playerMarker = new PlayerMarker(); + @FXML void initialize() { this.zoomBehaviour = Config.smoothZooming() ? new SmoothZooming() : new SnapZooming(); this.regionHandler = new RegionImageHandler(); + this.markerHandler = new MapMarkerHandler(); this.bounds = new Bounds(); WorldManager manager = WorldManager.getInstance(); @@ -91,6 +99,10 @@ void initialize() { manager.setPlayerPosListener(this::updatePlayerPos); GuiManager.setGraphicsHandler(this); + GuiManager.getStage().setResizable(true); + GuiManager.getStage().setHeight(500); + GuiManager.getStage().setWidth(700); + AnimationTimer animationTimer = new AnimationTimer() { @Override public void handle(long time) { @@ -107,6 +119,9 @@ public void handle(long time) { this.blocksPerPixel = newBlocksPerPixel; this.gridSize = (int) Math.round((32 * 16) / newBlocksPerPixel); }); + zoomBehaviour.onTargetChange(newTarget -> { + this.targetBlocksPerPixel = newTarget; + }); playerLockButton.setVisible(false); @@ -123,7 +138,7 @@ private void setupHelpLabel() { entityCanvas.setOnMouseEntered(e -> { mouseOver = true; if (playerHasConnected && !showErrorPrompt) { - helpLabel.setText("Right-click to open context menu. Scroll or +/- to zoom. Drag to pan."); + helpLabel.setText("Right-click to open context menu. Scroll or +/- to zoom. Drag to pan. Shift-click to measure distance."); } }); entityCanvas.setOnMouseMoved(e -> { @@ -133,18 +148,26 @@ private void setupHelpLabel() { int worldX = (int) Math.round((bounds.getMinX() + (mouseX * blocksPerPixel))); int worldZ = (int) Math.round((bounds.getMinZ() + (mouseY * blocksPerPixel))); - Coordinate2D coords = new Coordinate2D(worldX, worldZ); + cursorPos = new Coordinate2D(worldX, worldZ); + + String label = cursorPos.toString(); - String label = coords.toString(); if (Config.isInDevMode()) { - label += String.format("\t\tchunk: %s", coords.globalToChunk()); - label += String.format("\t\tregion: %s", coords.globalToRegion()); + label += String.format("\t\tchunk: %s", cursorPos.globalToChunk()); + label += String.format("\t\tregion: %s", cursorPos.globalToRegion()); + } + + int distance = markerHandler.getDistance(cursorPos); + if (distance > 0) { + label += String.format("\t\tDistance: %s blocks", PrintUtils.humanReadable(distance)); } + coordsLabel.setText(label); }); entityCanvas.setOnMouseExited(e -> { mouseOver = false; coordsLabel.setText(""); + cursorPos = null; if (playerHasConnected && !showErrorPrompt) { helpLabel.setText(""); } @@ -178,6 +201,11 @@ private void setupDragging() { if (!lockedToPlayer) { this.playerLockButton.setVisible(true); } + + // if mouse clicked without dragging + if (this.initialMouseX == mouseX && initialMouseY == mouseY) { + markerHandler.setMarker(null); + } }); entityCanvas.setOnMouseDragged((e) -> { @@ -205,8 +233,8 @@ private void followPlayer() { } private void setupCanvasProperties() { - setSmoothingState(chunkCanvas.getGraphicsContext2D(), false); - setSmoothingState(entityCanvas.getGraphicsContext2D(), true); + chunkCanvas.getGraphicsContext2D().setImageSmoothing(false); + entityCanvas.getGraphicsContext2D().setImageSmoothing(true); entityCanvas.getGraphicsContext2D().setTextAlign(TextAlignment.CENTER); entityCanvas.getGraphicsContext2D().setFont(Font.font(null, FontWeight.BOLD, 14)); @@ -224,71 +252,16 @@ private void setupCanvasProperties() { chunkCanvas.setStyle("-fx-background-color: rgb(51, 151, 51)"); } - /** - * Draw an image to the given canvas. In Java 9+, this just calls drawImage. In Java 8 drawImage causes super - * ugly artifacts due to forced interpolation, so to avoid this we manually draw the image and do nearest neighbour - * interpolation. - */ - private void drawImage(GraphicsContext ctx, int drawX, int drawY, Image img) { - if (enableModernImageHandling) { - ctx.drawImage(img, drawX, drawY, gridSize, gridSize); - return; - } - - // since this drawing method does not support out of bounds drawing, check for bounds first - if (drawX < 0 || drawY < 0 || gridSize < 1) { - return; - } - if (drawX + gridSize > ctx.getCanvas().getWidth() || drawY + gridSize > ctx.getCanvas().getHeight()) { - return; - } - - // if drawBlack is enabled, we remove transparency by doing a bitwise or with this mask. - int colMask = 0; - - double imgSize = img.getWidth(); - - // for performance reasons, we read all pixels and write pixels through arrays. We only touch the pixel - // reader/writer at the start and end. - int imgWidth = (int) imgSize; - int[] input = new int[imgWidth * imgWidth]; - int[] output = new int[gridSize * gridSize]; - - WritablePixelFormat format = WritablePixelFormat.getIntArgbInstance(); - img.getPixelReader().getPixels(0, 0, imgWidth, imgWidth, format, input, 0, imgWidth); - - - // in the loop we use the ratio to calculate where a pixel fom the input image ends up in the output - double ratio = imgSize / gridSize; - for (int x = 0; x < gridSize; x++) { - for (int y = 0; y < gridSize; y++) { - int imgX = (int) (x * ratio); - int imgY = (int) (y * ratio); - - output[x + y * gridSize] = input[imgX + imgY * imgWidth] | colMask; - } - } - - ctx.getPixelWriter().setPixels(drawX, drawY, gridSize, gridSize, format, output, 0, gridSize); - } - - private void setSmoothingState(GraphicsContext ctx, boolean value) { - try { - Method m = ctx.getClass().getMethod("setImageSmoothing", boolean.class); - m.invoke(ctx, value); - } catch (NoSuchMethodError | NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) { - enableModernImageHandling = false; - // if we can't set the image smoothing, we're likely on an older Java version. We will draw it manually - // so that we can use Nearest Neighbour interpolation. - } - } - private void setupContextMenu() { ContextMenu menu = new RightClickMenu(this); entityCanvas.setOnContextMenuRequested(e -> menu.show(entityCanvas, e.getScreenX(), e.getScreenY())); entityCanvas.setOnMouseClicked(e -> { if (e.getButton() == MouseButton.PRIMARY) { - menu.hide(); + if (menu.isShowing()) { + menu.hide(); + } else if (e.isShiftDown()) { + markerHandler.setMarker(cursorPos); + } } }); } @@ -307,7 +280,7 @@ void setChunkLoaded(CoordinateDim2D coord, Chunk chunk) { } ChunkImageFactory imageFactory = chunk.getChunkImageFactory(); - imageFactory.onComplete((image, isSaved) -> regionHandler.drawChunk(coord, image, isSaved)); + imageFactory.onComplete((imageMap, isSaved) -> regionHandler.drawChunk(coord, imageMap, isSaved)); imageFactory.onSaved(() -> regionHandler.markChunkSaved(coord)); imageFactory.requestImage(); } @@ -335,16 +308,38 @@ private void drawWorld() { graphics.setFill(BACKGROUND_COLOR); graphics.fillRect(0, 0, width.get(), height.get()); - regionHandler.drawAll(bounds, this::drawRegion); + regionHandler.drawAll(bounds, targetBlocksPerPixel, this::drawRegion); + + drawDebugHighlight(graphics); } + private void drawDebugHighlight(GraphicsContext graphics) { + if (!Config.isInDevMode() || cursorPos == null) { + return; + } + + if (blocksPerPixel > 1) { + Coordinate2D activeRegion = cursorPos.globalToRegion(); + int drawX = (int) Math.round(((32 * 16 * activeRegion.getX()) - bounds.getMinX()) / blocksPerPixel); + int drawY = (int) Math.round(((32 * 16 * activeRegion.getZ()) - bounds.getMinZ()) / blocksPerPixel); + + graphics.setFill(Color.rgb(0, 0, 0, .2)); + graphics.fillRect(drawX, drawY, gridSize, gridSize); + } else { + Coordinate2D activeChunk = cursorPos.globalToChunk(); + int drawX = (int) Math.round(((16 * activeChunk.getX()) - bounds.getMinX()) / blocksPerPixel); + int drawY = (int) Math.round(((16 * activeChunk.getZ()) - bounds.getMinZ()) / blocksPerPixel); + + graphics.setFill(Color.rgb(0, 0, 0, .2)); + graphics.fillRect(drawX, drawY, gridSize / 32, gridSize / 32); + } + } public void updatePlayerPos(CoordinateDouble3D playerPos, double rot) { this.playerPos = playerPos; this.playerRotation = rot; } - private void drawEntities() { GraphicsContext graphics = entityCanvas.getGraphicsContext2D(); graphics.clearRect(0, 0, width.get(), height.get()); @@ -354,6 +349,7 @@ private void drawEntities() { drawOtherPlayer(graphics, player); } } + markerHandler.draw(bounds, blocksPerPixel, graphics, cursorPos); drawPlayer(graphics); } @@ -391,22 +387,17 @@ private void drawPlayer(GraphicsContext graphics) { double playerX = ((playerPos.getX() - bounds.getMinX()) / blocksPerPixel); double playerZ = ((playerPos.getZ() - bounds.getMinZ()) / blocksPerPixel); - // direction pointer - double yaw = Math.toRadians(this.playerRotation + 45); - double pointerX = (playerX + (3)*Math.cos(yaw) - (3)*Math.sin(yaw)); - double pointerZ = (playerZ + (3)*Math.sin(yaw) + (3)*Math.cos(yaw)); + playerMarker.transform(playerX, playerZ, this.playerRotation, 0.7); + // marker + graphics.setFillRule(FillRule.NON_ZERO); graphics.setFill(Color.WHITE); - graphics.setStroke(Color.BLACK); - graphics.strokeOval((int) playerX - 4, (int) playerZ - 4, 8, 8); - graphics.strokeOval((int) pointerX - 2, (int) pointerZ - 2, 4, 4); - graphics.fillOval((int) playerX - 4, (int) playerZ - 4, 8, 8); - graphics.fillOval((int) pointerX - 2, (int) pointerZ - 2, 4, 4); + graphics.fillPolygon(playerMarker.getPointsX(), playerMarker.getPointsY(), playerMarker.count()); + graphics.setStroke(PLAYER_STROKE); + graphics.strokePolygon(playerMarker.getPointsX(), playerMarker.getPointsY(), playerMarker.count()); // indicator circle - graphics.setFill(Color.TRANSPARENT); graphics.setStroke(Color.RED); - graphics.strokeOval((int) playerX - 16, (int) playerZ - 16, 32, 32); } @@ -420,7 +411,7 @@ private void drawRegion(Coordinate2D pos, Image image) { int drawX = (int) Math.round((globalPos.getX() - bounds.getMinX()) / blocksPerPixel); int drawY = (int) Math.round((globalPos.getZ() - bounds.getMinZ()) / blocksPerPixel); - drawImage(graphics, drawX, drawY, image); + graphics.drawImage(image, drawX, drawY, gridSize, gridSize); } public void setDimension(Dimension dimension) { @@ -447,8 +438,8 @@ private void updateStatusPrompt() { } } - public int imageCount() { - return regionHandler.size(); + public String imageStats() { + return regionHandler.stats(); } public Coordinate2D getCenter() { diff --git a/src/main/java/gui/GuiSettings.java b/src/main/java/gui/GuiSettings.java index 4c46b57c..fc93c341 100644 --- a/src/main/java/gui/GuiSettings.java +++ b/src/main/java/gui/GuiSettings.java @@ -9,13 +9,11 @@ import gui.components.LongField; import java.io.File; import java.io.IOException; -import java.net.MalformedURLException; import java.net.ServerSocket; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Arrays; import java.util.List; +import java.util.Map; import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; @@ -59,17 +57,22 @@ public class GuiSettings { public Hyperlink verifyAuthLink; public CheckBox renderOtherPlayers; public CheckBox enableInfoMessages; - public Tab realmsTab; + public Tab generalTab; public Tab authTab; + public Tab realmsTab; public RealmsTabController realmsController; public AuthTabController authController; public CheckBox enableDrawExtendedChunks; + public CheckBox enableCaveRenderMode; Config config; private boolean portInUse; + Map heights; + public GuiSettings() { this.config = GuiManager.getConfig(); + GuiManager.getStage().setResizable(false); GuiManager.registerSettingController(this); } @@ -79,6 +82,11 @@ void initialize() { saveButton.setText("Save"); } + heights = Map.of( + generalTab, 360, + realmsTab, 320 + ); + // connection tab server.setText(config.server); portLocal.setText("" + config.portLocal); @@ -97,6 +105,7 @@ void initialize() { markOld.setSelected(config.markOldChunks); renderOtherPlayers.setSelected(config.renderOtherPlayers); enableInfoMessages.setSelected(!config.disableInfoMessages); + enableCaveRenderMode.setSelected(config.enableCaveRenderMode); enableDrawExtendedChunks.setSelected(config.drawExtendedChunks); // realms tab @@ -111,6 +120,12 @@ void initialize() { if (newVal == authTab) { authController.opened(this); } + + if (heights.containsKey(newVal)) { + GuiManager.getStage().setHeight(heights.get(newVal)); + } else { + resetHeight(); + } }); } disableWhenRunning(Arrays.asList(server, portLocal, centerX, centerZ, worldOutputDir)); @@ -133,11 +148,11 @@ void initialize() { handleErrorTab(); handleResizing(); - validateAuthentication(); + resetHeight(); } - private void validateAuthentication() { - + private void resetHeight() { + GuiManager.getStage().setHeight(290); } private void handleDataValidation() { @@ -291,6 +306,7 @@ private void save() { config.markOldChunks = markOld.isSelected(); config.renderOtherPlayers = renderOtherPlayers.isSelected(); config.disableInfoMessages = !enableInfoMessages.isSelected(); + config.enableCaveRenderMode = enableCaveRenderMode.isSelected(); config.drawExtendedChunks = enableDrawExtendedChunks.isSelected(); Config.save(); diff --git a/src/main/java/gui/RegionImage.java b/src/main/java/gui/RegionImage.java deleted file mode 100644 index b0d2c0a7..00000000 --- a/src/main/java/gui/RegionImage.java +++ /dev/null @@ -1,120 +0,0 @@ -package gui; - -import config.Config; -import game.data.chunk.Chunk; -import game.data.coordinates.Coordinate2D; -import game.data.region.Region; -import java.io.File; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.file.Path; -import java.nio.file.Paths; -import javafx.embed.swing.SwingFXUtils; -import javafx.scene.image.Image; -import javafx.scene.image.WritableImage; -import javafx.scene.image.WritablePixelFormat; -import javafx.scene.paint.Color; -import javax.imageio.ImageIO; - -public class RegionImage { - private static final int SIZE = Chunk.SECTION_WIDTH * Region.REGION_SIZE;; - WritableImage image; - WritableImage chunkOverlay; - byte[] buffer; - - boolean saved; - - public RegionImage() { - this(new WritableImage(SIZE, SIZE)); - } - - private RegionImage(WritableImage image) { - this.image = image; - this.buffer = new byte[16 * 16 * 4]; - this.saved = true; - - chunkOverlay = new WritableImage(Region.REGION_SIZE, Region.REGION_SIZE); - - // if mark old chunks is enabled, the overlay is initialised to the same as the GUI - // background color (with some opacity). Newly loaded chunks will make this transparent - // when loaded in. - if (Config.markOldChunks()) { - fillOverlay(ChunkImageState.OUTDATED.getColor()); - } - } - - /** - * Fills overlay with the given colour. - */ - private void fillOverlay(Color c) { - for (int i = 0; i < Region.REGION_SIZE; i++) { - for (int j = 0; j < Region.REGION_SIZE; j++) { - chunkOverlay.getPixelWriter().setColor(i, j, c); - } - } - } - - public static RegionImage of(Path directoryPath, Coordinate2D coordinate) { - File file = Paths.get(directoryPath.toString(), filename(coordinate)).toFile(); - - if (!file.exists()) { - return new RegionImage(); - } - - return of(file); - } - - public static RegionImage of(File image) { - try { - WritableImage im = new WritableImage(SIZE, SIZE); - SwingFXUtils.toFXImage(ImageIO.read(image), im); - - return new RegionImage(im); - } catch (IOException e) { - return new RegionImage(); - } - } - - public Image getImage() { - return image; - } - - public void drawChunk(Coordinate2D local, Image chunkImage) { - int size = Chunk.SECTION_WIDTH; - - WritablePixelFormat format = WritablePixelFormat.getByteBgraInstance(); - chunkImage.getPixelReader().getPixels(0, 0, size, size, format, buffer, 0, size * 4); - image.getPixelWriter().setPixels(local.getX() * size, local.getZ() * size, size, size, format, buffer, 0, size * 4); - - saved = false; - } - - public void colourChunk(Coordinate2D local, Color color) { - if (chunkOverlay != null) { - chunkOverlay.getPixelWriter().setColor(local.getX(), local.getZ(), color); - } - } - - public void save(Path p, Coordinate2D coords) throws IOException { - if (saved) { - return; - } - saved = true; - - File f = getFile(p, coords); - ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", f); - } - - public static File getFile(Path p, Coordinate2D coords) { - return Path.of(p.toString(), filename(coords)).toFile(); - } - - private static String filename(Coordinate2D coords) { - return "r." + coords.getX() + "." + coords.getZ() + ".png"; - } - - public Image getChunkOverlay() { - return chunkOverlay; - } -} - diff --git a/src/main/java/gui/RegionImageHandler.java b/src/main/java/gui/RegionImageHandler.java deleted file mode 100644 index 4e656e4d..00000000 --- a/src/main/java/gui/RegionImageHandler.java +++ /dev/null @@ -1,179 +0,0 @@ -package gui; - -import static util.ExceptionHandling.attempt; -import static util.ExceptionHandling.attemptQuiet; - -import config.Config; -import game.data.coordinates.Coordinate2D; -import game.data.coordinates.CoordinateDim2D; -import game.data.dimension.Dimension; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.BiConsumer; -import javafx.scene.image.Image; -import javafx.scene.paint.Color; -import org.apache.commons.io.FileUtils; - -/** - * Class to manage overlay images. - */ -public class RegionImageHandler { - private static final String CACHE_PATH = "image-cache"; - private Map regions; - private Dimension activeDimension; - private boolean isSaving = false; - - private final ScheduledExecutorService saveService; - - public RegionImageHandler() { - this.regions = new ConcurrentHashMap<>(); - - // TODO: remove or compress overview images far away to reduce memory usage - saveService = Executors.newSingleThreadScheduledExecutor( - (r) -> new Thread(r, "Region Image Handler") - ); - saveService.scheduleWithFixedDelay(this::save, 20, 20, TimeUnit.SECONDS); - } - - public void clear() { - unload(); - attemptQuiet(() -> FileUtils.deleteDirectory(Paths.get(Config.getWorldOutputDir(), CACHE_PATH).toFile())); - } - - public void drawChunk(CoordinateDim2D coordinate, Image chunkImage, Boolean isSaved) { - if (!coordinate.getDimension().equals(activeDimension)) { - return; - } - - Coordinate2D region = coordinate.chunkToRegion(); - - RegionImage image = regions.computeIfAbsent(region, (coordinate2D -> loadRegion(coordinate))); - - Coordinate2D local = coordinate.toRegionLocal(); - image.drawChunk(local, chunkImage); - - setChunkState(image, local, ChunkImageState.isSaved(isSaved)); - } - - public void setChunkState(Coordinate2D coords, ChunkImageState state) { - Coordinate2D region = coords.chunkToRegion(); - - RegionImage image = regions.get(region); - if (image == null) { - return; - } - - setChunkState(image, coords.toRegionLocal(), state); - } - - private void setChunkState(RegionImage image, Coordinate2D local, ChunkImageState state) { - image.colourChunk(local, state.getColor()); - } - - - public void markChunkSaved(CoordinateDim2D coordinate) { - setChunkState(coordinate, ChunkImageState.SAVED); - } - - private RegionImage loadRegion(Coordinate2D coordinate) { - return RegionImage.of(dimensionPath(this.activeDimension), coordinate); - } - - private void save(Map regions, Dimension dim) { - // if shutdown is called, wait for saving to complete - if (isSaving) { - if (saveService != null) { - attempt(() -> saveService.awaitTermination(10, TimeUnit.SECONDS)); - } - return; - } - isSaving = true; - - attempt(() -> Files.createDirectories(dimensionPath(dim))); - regions.forEach((coordinate, image) -> { - attempt(() -> image.save(dimensionPath(dim), coordinate)); - }); - - isSaving = false; - } - - public void save() { - save(this.regions, this.activeDimension); - } - - private void unload() { - this.regions = new ConcurrentHashMap<>(); - } - - /** - * Searches for all region files in a directory to load them in. - */ - private void load() { - Map regionMap = regions; - - new Thread(() -> attemptQuiet(() -> { - Files.walk(dimensionPath(this.activeDimension), 1).limit(3200) - .forEach(image -> attempt(() -> { - if (!image.toString().toLowerCase().endsWith("png")) { - return; - } - - String[] parts = image.getFileName().toString().split("\\."); - - int x = Integer.parseInt(parts[1]); - int z = Integer.parseInt(parts[2]); - Coordinate2D regionCoordinate = new Coordinate2D(x, z); - - regionMap.put(regionCoordinate, RegionImage.of(image.toFile())); - })); - })).start(); - } - - public void setDimension(Dimension dimension) { - if (this.activeDimension == dimension) { - return; - } - - if (this.activeDimension != null) { - save(); - unload(); - } - - this.activeDimension = dimension; - load(); - } - - private static Path dimensionPath(Dimension dim) { - return Paths.get(Config.getWorldOutputDir(), CACHE_PATH, dim.getPath()); - } - - public void drawAll(Bounds bounds, BiConsumer drawRegion) { - regions.forEach((coordinate, image) -> { - if (bounds.overlaps(coordinate)) { - drawRegion.accept(coordinate, image.getImage()); - drawRegion.accept(coordinate, image.getChunkOverlay()); - } - }); - } - - public int size() { - return regions.size(); - } - - public void shutdown() { - if (saveService != null) { - saveService.shutdown(); - } - } - - public void resetRegion(Coordinate2D region) { - attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension), region))); - regions.remove(region); - } -} diff --git a/src/main/java/gui/RightClickMenu.java b/src/main/java/gui/RightClickMenu.java index a8c1011f..34d25db9 100644 --- a/src/main/java/gui/RightClickMenu.java +++ b/src/main/java/gui/RightClickMenu.java @@ -8,6 +8,8 @@ import game.data.coordinates.CoordinateDim2D; import game.data.dimension.Dimension; import game.data.region.McaFile; +import gui.images.ImageMode; +import gui.images.RegionImageHandler; import java.awt.Toolkit; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.StringSelection; @@ -16,18 +18,29 @@ import javafx.event.EventType; import javafx.scene.control.*; import javafx.stage.Stage; -import util.PathUtils; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectOutputStream; -import java.nio.file.Path; import java.util.List; public class RightClickMenu extends ContextMenu { final static String PROMPT_PAUSE = "Pause chunk saving"; final static String PROMPT_RESUME = "Resume chunk saving"; + final List renderModes = List.of( + construct("Automatic", e -> setRenderMode(null, e)), + construct("Surface", e -> setRenderMode(ImageMode.NORMAL, e)), + construct("Caves", e -> setRenderMode(ImageMode.CAVES, e)) + ); + + private void setRenderMode(ImageMode mode, Event e) { + renderModes.forEach(el -> el.setDisable(false)); + MenuItem clicked = (MenuItem) e.getTarget(); + clicked.setDisable(true); + + RegionImageHandler.setOverrideMode(mode); + } public RightClickMenu(GuiMap handler) { List menu = this.getItems(); @@ -73,6 +86,7 @@ public RightClickMenu(GuiMap handler) { new Thread(() -> WorldManager.getInstance().drawExistingRegion(region)).start(); })); + menu.add(construct("Copy coordinates", e -> { Coordinate2D coords = handler.getCursorCoordinates(); String coordsString = String.format("%d ~ %d", coords.getX(), coords.getZ()); @@ -83,6 +97,10 @@ public RightClickMenu(GuiMap handler) { menu.add(new SeparatorMenuItem()); + ImageMode current = RegionImageHandler.getOverrideMode(); + + + menu.add(new Menu("Render mode", null, renderModes.toArray(new MenuItem[0]))); menu.add(construct("Settings", e -> GuiManager.loadWindowSettings())); menu.add(construct("Save & Exit", e -> { @@ -125,7 +143,7 @@ private void addDevOptions(List menu, GuiMap handler) { int entities = WorldManager.getInstance().getEntityRegistry().countActiveEntities(); int players = WorldManager.getInstance().getEntityRegistry().countActivePlayers(); int maps = WorldManager.getInstance().getMapRegistry().countActiveMaps(); - int images = handler.imageCount(); + String imageStats = handler.imageStats(); System.out.printf("Statistics:" + "\n\tActive regions: %d" + @@ -136,9 +154,9 @@ private void addDevOptions(List menu, GuiMap handler) { "\n\tActive entities: %d" + "\n\tActive players: %d" + "\n\tActive maps: %d" + - "\n\tActive region images: %d" + + "\n\tActive region images: %s" + "\n", - regions, binaryChunks, unpasedChunks, chunks, extendedChunks, entities, players, maps, images); + regions, binaryChunks, unpasedChunks, chunks, extendedChunks, entities, players, maps, imageStats); })); menu.add(construct("Print chunk events", e -> { @@ -146,6 +164,12 @@ private void addDevOptions(List menu, GuiMap handler) { })); } + private MenuItem construct(String name, boolean isDisabled, HandleError handler) { + MenuItem item = construct(name, handler); + item.setDisable(isDisabled); + return item; + } + private MenuItem construct(String name, HandleError handler) { MenuItem item = new MenuItem(name); item.addEventHandler(EventType.ROOT, handler); diff --git a/src/main/java/gui/SmoothZooming.java b/src/main/java/gui/SmoothZooming.java index 10e76d7d..c5eb9a58 100644 --- a/src/main/java/gui/SmoothZooming.java +++ b/src/main/java/gui/SmoothZooming.java @@ -6,13 +6,19 @@ public class SmoothZooming implements ZoomBehaviour { private final InterpolatedDouble blocksPerPixel; - DoubleConsumer onChange; + DoubleConsumer onChange, onTargetChange; public SmoothZooming() { this.blocksPerPixel = new InterpolatedDouble(.2e9, 1.0); this.onChange = (v) -> {}; } + @Override + public void onTargetChange(DoubleConsumer onTargetChange) { + this.onTargetChange = onTargetChange; + onTargetChange.accept(this.blocksPerPixel.getCurrentValue()); + } + @Override public void onChange(DoubleConsumer setBlocksPerPixel) { this.onChange = setBlocksPerPixel; @@ -26,6 +32,7 @@ public void bind(Node targetElement) { targetVal = Math.max(minBlocksPerPixel, Math.min(targetVal, maxBlocksPerPixel)); + onTargetChange.accept(targetVal); blocksPerPixel.setTargetValue(targetVal); }; bind(targetElement, handleZoom); diff --git a/src/main/java/gui/SnapZooming.java b/src/main/java/gui/SnapZooming.java index 0bc5bc54..d1a37706 100644 --- a/src/main/java/gui/SnapZooming.java +++ b/src/main/java/gui/SnapZooming.java @@ -5,7 +5,7 @@ public class SnapZooming implements ZoomBehaviour { private double blocksPerPixel; - private DoubleConsumer onChange; + private DoubleConsumer onChange, onTargetChange; public SnapZooming() { this.blocksPerPixel = initialBlocksPerPixel; @@ -17,6 +17,12 @@ public void onChange(DoubleConsumer setBlocksPerPixel) { onChange.accept(this.blocksPerPixel); } + @Override + public void onTargetChange(DoubleConsumer onTargetChange) { + this.onTargetChange = onTargetChange; + onTargetChange.accept(this.blocksPerPixel); + } + @Override public void bind(Node targetElement) { DoubleConsumer handleZoom = (multiplier) -> { @@ -25,6 +31,7 @@ public void bind(Node targetElement) { if (blocksPerPixel > maxBlocksPerPixel) { blocksPerPixel = maxBlocksPerPixel; } else if (blocksPerPixel < minBlocksPerPixel) { blocksPerPixel = minBlocksPerPixel; } + onTargetChange.accept(blocksPerPixel); onChange.accept(blocksPerPixel); }; bind(targetElement, handleZoom); diff --git a/src/main/java/gui/ZoomBehaviour.java b/src/main/java/gui/ZoomBehaviour.java index 62866175..405b7fe0 100644 --- a/src/main/java/gui/ZoomBehaviour.java +++ b/src/main/java/gui/ZoomBehaviour.java @@ -31,4 +31,6 @@ default void bind(Node targetElement, DoubleConsumer handleZoom) { handleZoom.accept(scrollEvent.getDeltaY() > 0 ? zoomOutMultiplier : zoomInMultiplier); }); } + + void onTargetChange(DoubleConsumer onTargetChange); } diff --git a/src/main/java/gui/images/ImageMode.java b/src/main/java/gui/images/ImageMode.java new file mode 100644 index 00000000..adb5223c --- /dev/null +++ b/src/main/java/gui/images/ImageMode.java @@ -0,0 +1,20 @@ +package gui.images; + +public enum ImageMode { + NORMAL(""), CAVES("caves"); + + final String path; + ImageMode(String path) { + this.path = path; + } + + public ImageMode other() { + if (this == NORMAL) { + return CAVES; + } + return NORMAL; + } + + public String path() { return path; } +} + diff --git a/src/main/java/gui/images/RegionImage.java b/src/main/java/gui/images/RegionImage.java new file mode 100644 index 00000000..36162042 --- /dev/null +++ b/src/main/java/gui/images/RegionImage.java @@ -0,0 +1,263 @@ +package gui.images; + +import config.Config; +import game.data.chunk.Chunk; +import game.data.coordinates.Coordinate2D; +import game.data.region.Region; +import gui.ChunkImageState; +import java.awt.AlphaComposite; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Map; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.image.Image; +import javafx.scene.image.WritableImage; +import javafx.scene.image.WritablePixelFormat; +import javafx.scene.paint.Color; +import javax.imageio.ImageIO; + +public class RegionImage { + public static final String NORMAL_PREFIX = ""; + public static final String SMALL_PREFIX = "small_"; + + private static final int MIN_SIZE = 16; + private static final long MIN_WAIT_TIME = 30 * 1000; + private static final int SIZE = Chunk.SECTION_WIDTH * Region.REGION_SIZE;; + + // since all resizing happens on the same thread, we can re-use buffered image objects to reduce + // memory usage + private static final Map TEMP_IMAGES = Map.of( + 16, new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB), + 32, new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB), + 64, new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB), + 128, new BufferedImage(128, 128, BufferedImage.TYPE_INT_ARGB), + 256, new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB), + 512, new BufferedImage(512, 512, BufferedImage.TYPE_INT_ARGB) + ); + long lastUpdated; + + int currentSize = SIZE; + int targetSize = SIZE; + + private final Path path; + final Coordinate2D coordinates; + + WritableImage image; + WritableImage chunkOverlay; + byte[] buffer; + + boolean saved; + + public RegionImage(Path path, Coordinate2D coords) { + this(new WritableImage(MIN_SIZE, MIN_SIZE), path, coords); + this.currentSize = MIN_SIZE; + this.targetSize = MIN_SIZE; + } + + private RegionImage(WritableImage image, Path path, Coordinate2D coords) { + this.currentSize = MIN_SIZE; + this.targetSize = MIN_SIZE; + this.path = path; + + this.image = image; + this.buffer = new byte[16 * 16 * 4]; + this.saved = true; + this.coordinates = coords; + + chunkOverlay = new WritableImage(Region.REGION_SIZE, Region.REGION_SIZE); + + // if mark old chunks is enabled, the overlay is initialised to the same as the GUI + // background color (with some opacity). Newly loaded chunks will make this transparent + // when loaded in. + if (Config.markOldChunks()) { + fillOverlay(ChunkImageState.OUTDATED.getColor()); + } + } + + /** + * Fills overlay with the given colour. + */ + private void fillOverlay(Color c) { + for (int i = 0; i < Region.REGION_SIZE; i++) { + for (int j = 0; j < Region.REGION_SIZE; j++) { + chunkOverlay.getPixelWriter().setColor(i, j, c); + } + } + } + + public static RegionImage of(Path directoryPath, Coordinate2D coordinate) { + try { + WritableImage image = loadFromFile(directoryPath, coordinate, MIN_SIZE); + + return new RegionImage(image, directoryPath, coordinate); + } catch (IOException e) { + return new RegionImage(directoryPath, coordinate); + } + } + + public boolean setTargetSize(boolean isVisible, double blocksPerPixel) { + // don't resize if we recently wrote chunks since it is likely to be written to again + if (!saved || System.currentTimeMillis() - lastUpdated < MIN_WAIT_TIME) { + return false; + } + + int newTarget = isVisible ? (int) (Math.min(Math.max(SIZE / blocksPerPixel, MIN_SIZE), SIZE)) : MIN_SIZE; + if (newTarget != targetSize) { + targetSize = newTarget; + return true; + } + return false; + } + + private static BufferedImage resize(BufferedImage original, int targetSize) { + BufferedImage resizedImage = TEMP_IMAGES.get(targetSize); + + Graphics2D graphics2D = resizedImage.createGraphics(); + graphics2D.setComposite(AlphaComposite.Clear); + graphics2D.fillRect(0, 0, targetSize, targetSize); + + graphics2D.setComposite(AlphaComposite.DstAtop); + graphics2D.drawImage(original, 0, 0, targetSize, targetSize, null); + graphics2D.dispose(); + + return resizedImage; + } + + private static WritableImage loadFromFile(Path path, Coordinate2D coordinate, int targetSize) throws IOException { + File smallFile = getFile(path, SMALL_PREFIX, coordinate); + if (targetSize == MIN_SIZE && smallFile.exists()) { + return loadSmall(smallFile); + } else { + return loadFromFile(getFile(path, NORMAL_PREFIX, coordinate), targetSize); + } + } + + private static WritableImage loadSmall(File file) throws IOException { + WritableImage image = new WritableImage(MIN_SIZE, MIN_SIZE); + SwingFXUtils.toFXImage(ImageIO.read(file), image); + + return image; + } + + private static WritableImage loadFromFile(File file, int targetSize) throws IOException { + WritableImage im = new WritableImage(targetSize, targetSize); + + if (file != null && file.exists()) { + BufferedImage image = ImageIO.read(file); + + if (targetSize < SIZE) { + image = resize(image, targetSize); + } + + SwingFXUtils.toFXImage(image, im); + + } + + return im; + } + + public void allowResample() { + if (targetSize > currentSize) { + upSample(); + } else if (targetSize < currentSize) { + downSample(); + } + } + + private void upSample() { + try { + image = loadFromFile(getFile(path, NORMAL_PREFIX, coordinates), targetSize); + currentSize = targetSize; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void downSample() { + if (targetSize == currentSize) { + return; + } + + // check again in case it changed in meantime due to multithreading + if (!saved || System.currentTimeMillis() - lastUpdated < MIN_WAIT_TIME) { + return; + } + + BufferedImage bufferedImage = TEMP_IMAGES.get(currentSize); + SwingFXUtils.fromFXImage(image, bufferedImage); + + bufferedImage = resize(bufferedImage, targetSize); + + image = SwingFXUtils.toFXImage(bufferedImage, null); + currentSize = targetSize; + } + + public Image getImage() { + return image; + } + + public void drawChunk(Coordinate2D local, Image chunkImage) { + lastUpdated = System.currentTimeMillis(); + saved = false; + + if (targetSize < SIZE || currentSize < SIZE) { + targetSize = SIZE; + + upSample(); + } + + drawChunkToImage(local, chunkImage); + } + + private void drawChunkToImage(Coordinate2D local, Image chunkImage) { + int size = Chunk.SECTION_WIDTH; + + WritablePixelFormat format = WritablePixelFormat.getByteBgraInstance(); + chunkImage.getPixelReader().getPixels(0, 0, size, size, format, buffer, 0, size * 4); + image.getPixelWriter().setPixels(local.getX() * size, local.getZ() * size, size, size, format, buffer, 0, size * 4); + + saved = false; + } + + public void colourChunk(Coordinate2D local, Color color) { + if (chunkOverlay != null) { + chunkOverlay.getPixelWriter().setColor(local.getX(), local.getZ(), color); + } + } + + public void save() throws IOException { + if (saved) { + return; + } + saved = true; + + File f = getFile(path, NORMAL_PREFIX, coordinates); + BufferedImage img = SwingFXUtils.fromFXImage(image, null); + ImageIO.write(img, "png", f); + + img = resize(img, MIN_SIZE); + f = getFile(path, SMALL_PREFIX, coordinates); + ImageIO.write(img, "png", f); + } + + public static File getFile(Path p, String prefix, Coordinate2D coords) { + return Path.of(p.toString(), prefix + filename(coords)).toFile(); + } + + private static String filename(Coordinate2D coords) { + return "r." + coords.getX() + "." + coords.getZ() + ".png"; + } + + public Image getChunkOverlay() { + return chunkOverlay; + } + + public int getSize() { + return currentSize; + } +} + diff --git a/src/main/java/gui/images/RegionImageHandler.java b/src/main/java/gui/images/RegionImageHandler.java new file mode 100644 index 00000000..e3aaff07 --- /dev/null +++ b/src/main/java/gui/images/RegionImageHandler.java @@ -0,0 +1,349 @@ +package gui.images; + +import static gui.images.RegionImage.NORMAL_PREFIX; +import static gui.images.RegionImage.SMALL_PREFIX; +import static util.ExceptionHandling.attempt; +import static util.ExceptionHandling.attemptQuiet; + +import config.Config; +import game.data.WorldManager; +import game.data.coordinates.Coordinate2D; +import game.data.coordinates.CoordinateDim2D; +import game.data.dimension.Dimension; +import gui.Bounds; +import gui.ChunkImageState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import javafx.scene.image.Image; +import javafx.scene.paint.Color; +import org.apache.commons.io.FileUtils; + +/** + * Class to manage overlay images. + */ +public class RegionImageHandler { + private static final String CACHE_PATH = "image-cache"; + private Map regions; + private Dimension activeDimension; + private boolean isSaving = false; + + private final ScheduledExecutorService imageHandlerExecutor; + private static ImageMode overrideMode; + private ImageMode imageMode = ImageMode.NORMAL; + + ConcurrentLinkedQueue resizeLater; + + + public RegionImageHandler() { + this.regions = new ConcurrentHashMap<>(); + this.resizeLater = new ConcurrentLinkedQueue<>(); + + imageHandlerExecutor = Executors.newSingleThreadScheduledExecutor( + (r) -> new Thread(r, "Region Image Handler") + ); + imageHandlerExecutor.scheduleWithFixedDelay(this::save, 20, 20, TimeUnit.SECONDS); + imageHandlerExecutor.scheduleWithFixedDelay(this::resizeLater, 15, 5, TimeUnit.SECONDS); + } + + private void resizeLater() { + while (!resizeLater.isEmpty()) { + resizeLater.remove().allowResample(); + } + } + + public static ImageMode getOverrideMode() { + return overrideMode; + } + + public static void setOverrideMode(ImageMode imageMode) { + overrideMode = imageMode; + } + + public void clear() { + unload(); + attemptQuiet(() -> FileUtils.deleteDirectory(Paths.get(Config.getWorldOutputDir(), CACHE_PATH).toFile())); + } + + public void drawChunk(CoordinateDim2D coordinate, Map imageMap, Boolean isSaved) { + if (!coordinate.getDimension().equals(activeDimension)) { + return; + } + + Coordinate2D region = coordinate.chunkToRegion(); + RegionImages images = regions.computeIfAbsent(region, (coordinate2D -> loadRegion(region))); + + Coordinate2D local = coordinate.toRegionLocal(); + + imageHandlerExecutor.schedule(() -> { + imageMap.forEach((mode, image) -> { + images.getImage(mode).drawChunk(local, image); + }); + }, 0, TimeUnit.MILLISECONDS); + + + setChunkState(images, local, ChunkImageState.isSaved(isSaved)); + } + + public void setChunkState(Coordinate2D coords, ChunkImageState state) { + Coordinate2D region = coords.chunkToRegion(); + + RegionImages images = regions.get(region); + if (images == null) { + return; + } + + setChunkState(images, coords.toRegionLocal(), state); + } + + private void setChunkState(RegionImages image, Coordinate2D local, ChunkImageState state) { + image.colourChunk(local, state.getColor()); + } + + + public void markChunkSaved(CoordinateDim2D coordinate) { + setChunkState(coordinate, ChunkImageState.SAVED); + } + + private RegionImages loadRegion(Coordinate2D coordinate) { + return RegionImages.of(activeDimension, coordinate); + } + + private void allowResample(Map regions, Dimension activeDimension) { + regions.forEach((coordinate, image) -> { + attempt(() -> image.allowResample()); + }); + } + + private void save(Map regions, Dimension dim) { + // if shutdown is called, wait for saving to complete + if (isSaving) { + if (imageHandlerExecutor != null) { + attempt(() -> imageHandlerExecutor.awaitTermination(10, TimeUnit.SECONDS)); + } + return; + } + isSaving = true; + + for (ImageMode mode : ImageMode.values()) { + attempt(() -> Files.createDirectories(dimensionPath(dim, mode))); + } + + regions.forEach((coordinate, image) -> { + attempt(image::save); + }); + + isSaving = false; + } + + public void save() { + save(this.regions, this.activeDimension); + } + + public void allowResample() { + allowResample(this.regions, this.activeDimension); + } + + private void unload() { + this.regions = new ConcurrentHashMap<>(); + } + + /** + * Searches for all region files in a directory to load them in. + */ + private void load() { + Map regionMap = regions; + + new Thread(() -> attemptQuiet(() -> { + for (ImageMode mode : ImageMode.values()) { + Files.walk(dimensionPath(this.activeDimension, mode), 1) + .limit(3200) + .forEach(image -> attempt(() -> load(regionMap, mode, image))); + } + })).start(); + } + + private void load(Map regionMap, ImageMode mode, Path image) { + if (!image.toString().toLowerCase().endsWith("png") || image.getFileName().startsWith( + SMALL_PREFIX)) { + return; + } + + String[] parts = image.getFileName().toString().split("\\."); + + int x = Integer.parseInt(parts[1]); + int z = Integer.parseInt(parts[2]); + Coordinate2D regionCoordinate = new Coordinate2D(x, z); + + Path p = image.getParent(); + regionMap.computeIfAbsent(regionCoordinate, k -> new RegionImages(p, regionCoordinate)).set(mode, p); + } + + public void setDimension(Dimension dimension) { + if (this.activeDimension == dimension) { + return; + } + + if (this.activeDimension != null) { + save(); + unload(); + } + + this.activeDimension = dimension; + load(); + } + + static Path dimensionPath(Dimension dim) { + return Paths.get(Config.getWorldOutputDir(), CACHE_PATH, dim.getPath()); + } + + static Path dimensionPath(Dimension dim, ImageMode mode) { + return Paths.get(Config.getWorldOutputDir(), CACHE_PATH, mode.path(), dim.getPath()); + } + + private void updateRenderMode() { + boolean isNether = WorldManager.getInstance().getDimension().isNether(); + + if (overrideMode != null) { + imageMode = isNether ? overrideMode.other() : overrideMode; + return; + } + + if (Config.enableCaveRenderMode()) { + imageMode = WorldManager.getInstance().isBelowGround() ? ImageMode.CAVES : ImageMode.NORMAL; + } else { + imageMode = isNether ? ImageMode.CAVES : ImageMode.NORMAL; + } + } + + public void drawAll(Bounds bounds, double blocksPerPixel, BiConsumer drawRegion) { + updateRenderMode(); + + regions.forEach((coordinate, images) -> { + boolean isVisible = bounds.overlaps(coordinate); + + boolean shouldResize = images.updateSize(isVisible, imageMode, blocksPerPixel); + if (isVisible && shouldResize) { + imageHandlerExecutor.schedule(images::allowResample, 0, TimeUnit.MILLISECONDS); + } else if (shouldResize) { + resizeLater.add(images); + } + + if (isVisible) { + RegionImage image = images.getImage(imageMode); + if (image == null) { return; } + + drawRegion.accept(coordinate, image.getImage()); + drawRegion.accept(coordinate, image.getChunkOverlay()); + } + }); + } + + public String stats() { + int size = regions.size() * 2; + + Map counts = new HashMap<>(); + regions.forEach((k, v) -> { + int sizeNormal = v.normal.getSize();; + int sizeCave = v.caves.getSize();; + + counts.put(sizeNormal, counts.getOrDefault(sizeNormal, 0) + 1); + counts.put(sizeCave, counts.getOrDefault(sizeCave, 0) + 1); + }); + + String stats = counts.entrySet().stream().sorted(Comparator.comparing(Map.Entry::getKey)) + .map(e -> e.getValue() + "x" + e.getKey() + "px") + .collect(Collectors.joining(", ")); + + return size + " (" + stats + ")"; + } + + public void shutdown() { + if (imageHandlerExecutor != null) { + imageHandlerExecutor.shutdown(); + } + } + + public void resetRegion(Coordinate2D region) { + attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension, ImageMode.NORMAL), NORMAL_PREFIX, region))); + attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension, ImageMode.CAVES), NORMAL_PREFIX, region))); + + attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension, ImageMode.NORMAL), SMALL_PREFIX, region))); + attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension, ImageMode.CAVES), SMALL_PREFIX, region))); + regions.remove(region); + } +} + +class RegionImages { + Coordinate2D coordinate; + RegionImage normal; + RegionImage caves; + + public RegionImages(RegionImage normal, RegionImage caves) { + this.normal = normal; + this.caves = caves; + } + + public RegionImages(Path p, Coordinate2D coordinate2D) { + normal = new RegionImage(p, coordinate2D); + caves = new RegionImage(p, coordinate2D); + coordinate = coordinate2D; + } + + public static RegionImages of(Dimension dimension, Coordinate2D coordinate) { + RegionImage normal = RegionImage.of(RegionImageHandler.dimensionPath(dimension, ImageMode.NORMAL), coordinate); + RegionImage caves = RegionImage.of(RegionImageHandler.dimensionPath(dimension, ImageMode.CAVES), coordinate); + + return new RegionImages(normal, caves); + } + + public RegionImage getImage(ImageMode mode) { + return switch (mode) { + case NORMAL -> normal; + case CAVES -> caves; + }; + } + + public void colourChunk(Coordinate2D local, Color color) { + normal.colourChunk(local, color); + if (caves != null) { + caves.colourChunk(local, color); + } + } + + public void save() throws IOException { + normal.save(); + caves.save(); + } + + public void set(ImageMode mode, Path image) { + switch (mode) { + case NORMAL -> normal = RegionImage.of(image, coordinate); + case CAVES -> caves = RegionImage.of(image, coordinate); + }; + } + + public boolean updateSize(boolean isVisible, ImageMode mode, double blocksPerPixel) { + boolean shouldResize = caves.setTargetSize(isVisible && mode == ImageMode.CAVES, blocksPerPixel); + shouldResize |= normal.setTargetSize(isVisible && mode == ImageMode.NORMAL, blocksPerPixel); + + return shouldResize; + } + + public void allowResample() { + caves.allowResample(); + normal.allowResample(); + } +} + diff --git a/src/main/java/gui/markers/MapMarker.java b/src/main/java/gui/markers/MapMarker.java new file mode 100644 index 00000000..28f97799 --- /dev/null +++ b/src/main/java/gui/markers/MapMarker.java @@ -0,0 +1,63 @@ +package gui.markers; + +import com.sun.javafx.geom.transform.Translate2D; +import java.awt.geom.AffineTransform; +import javafx.geometry.Point2D; +import javafx.scene.transform.Rotate; +import javafx.scene.transform.Scale; +import javafx.scene.transform.Transform; +import javafx.scene.transform.Translate; +import javax.swing.tree.TreeNode; + +public abstract class MapMarker { + int size; + double[] yPoints, xPoints, inputPoints, outputPoints; + + AffineTransform transform; + + public MapMarker(int size) { + this.size = size; + this.xPoints = new double[size]; + this.yPoints = new double[size]; + this.inputPoints = new double[size * 2]; + this.outputPoints = new double[size * 2]; + + double[] x = getShapePointsX(); + double[] y = getShapePointsY(); + for (int i = 0; i < size; i++) { + this.inputPoints[i * 2] = x[i]; + this.inputPoints[i * 2 + 1] = y[i]; + } + + transform = new AffineTransform(); + } + + public void transform(double offsetX, double offsetZ, double rotation, double scale) { + transform.setToIdentity(); + transform.translate(offsetX, offsetZ); + transform.rotate(rotation * (Math.PI / 180)); + transform.scale(scale, scale); + + transform.transform(inputPoints, 0, outputPoints, 0, size); + + for (int i = 0; i < size; i++) { + xPoints[i] = this.outputPoints[i * 2]; + yPoints[i] = this.outputPoints[i * 2 + 1]; + } + } + + public int count() { + return xPoints.length; + } + + public double[] getPointsX() { + return xPoints; + } + + public double[] getPointsY() { + return yPoints; + } + + abstract double[] getShapePointsX(); + abstract double[] getShapePointsY(); +} diff --git a/src/main/java/gui/markers/MapMarkerHandler.java b/src/main/java/gui/markers/MapMarkerHandler.java new file mode 100644 index 00000000..5639cfbf --- /dev/null +++ b/src/main/java/gui/markers/MapMarkerHandler.java @@ -0,0 +1,53 @@ +package gui.markers; + +import game.data.coordinates.Coordinate2D; +import gui.Bounds; +import java.util.ArrayList; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; +import javafx.scene.shape.FillRule; +import util.PrintUtils; + +public class MapMarkerHandler { + Coordinate2D measureSource; + + public MapMarkerHandler() { + } + + public void setMarker(Coordinate2D pos) { + measureSource = pos; + } + + public int getDistance(Coordinate2D cursorPos) { + if (measureSource == null) { + return -1; + } + return cursorPos.distance(measureSource); + } + + public void draw(Bounds bounds, double blocksPerPixel, GraphicsContext graphics, Coordinate2D cursorPos) { + if (measureSource == null) { + return; + } + + int markerSize = 8; + double markerX = ((measureSource.getX() - bounds.getMinX() - 0.5) / blocksPerPixel); + double markerZ = ((measureSource.getZ() - bounds.getMinZ() - 0.5) / blocksPerPixel); + + // line + if (cursorPos != null) { + double destX = (cursorPos.getX() - bounds.getMinX() - 0.5) / blocksPerPixel; + double destZ = (cursorPos.getZ() - bounds.getMinZ() - 0.5) / blocksPerPixel; + + graphics.setStroke(Color.WHITE); + graphics.strokeLine(markerX, markerZ, destX, destZ); + } + + // marker + graphics.setFillRule(FillRule.NON_ZERO); + graphics.setFill(Color.WHITE); + graphics.fillOval(markerX - markerSize / 2, markerZ - markerSize / 2, 8, 8); + graphics.setStroke(Color.BLACK); + graphics.strokeOval(markerX - markerSize / 2, markerZ - markerSize / 2, 8, 8); + } +} diff --git a/src/main/java/gui/markers/PlayerMarker.java b/src/main/java/gui/markers/PlayerMarker.java new file mode 100644 index 00000000..d4de9374 --- /dev/null +++ b/src/main/java/gui/markers/PlayerMarker.java @@ -0,0 +1,20 @@ +package gui.markers; + +public class PlayerMarker extends MapMarker { + static double[] xPoints = { 0, 8.5, 0, -8.5 }; + static double[] yPoints = { 12, -8, -4, -8 }; + + public PlayerMarker() { + super(xPoints.length); + } + + @Override + double[] getShapePointsX() { + return xPoints; + } + + @Override + double[] getShapePointsY() { + return yPoints; + } +} diff --git a/src/main/java/packets/handler/ClientBoundGamePacketHandler.java b/src/main/java/packets/handler/ClientBoundGamePacketHandler.java index c55f08d5..4c0fd291 100644 --- a/src/main/java/packets/handler/ClientBoundGamePacketHandler.java +++ b/src/main/java/packets/handler/ClientBoundGamePacketHandler.java @@ -172,6 +172,12 @@ public ClientBoundGamePacketHandler(ConnectionManager connectionManager) { PluginChannelHandler.getInstance().handleCustomPayload(provider); return true; }); + + operations.put("SetChunkCacheRadius", provider -> { + int dist = provider.readVarInt(); + + return dist > Config.getExtendedRenderDistance(); + }); } public static PacketHandler of(ConnectionManager connectionManager) { diff --git a/src/main/java/util/PrintUtils.java b/src/main/java/util/PrintUtils.java index 8cb3a9ed..28417c64 100644 --- a/src/main/java/util/PrintUtils.java +++ b/src/main/java/util/PrintUtils.java @@ -2,9 +2,20 @@ import config.Config; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; import java.util.Arrays; public class PrintUtils { + private static final DecimalFormat formatter; + + static { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(); + symbols.setGroupingSeparator(' '); + + formatter = new DecimalFormat("###,###", symbols); + } + public static void devPrint(String out) { if (Config.isInDevMode()) { System.out.println(out); @@ -44,4 +55,9 @@ public static String array(byte[] arr) { } return "[" + arr[0] + ", ... (x" + (arr.length - 2) + "), " + arr[arr.length - 1] + "]"; } + + public static String humanReadable(int number) { + return formatter.format(number); + } + } diff --git a/src/main/resources/ui/Settings.fxml b/src/main/resources/ui/Settings.fxml index 6ee743e2..35158c88 100644 --- a/src/main/resources/ui/Settings.fxml +++ b/src/main/resources/ui/Settings.fxml @@ -4,38 +4,40 @@ - - + + - + - - + + - + - - - + + + - - + + @@ -45,9 +47,13 @@ + + @@ -55,49 +61,66 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - @@ -176,5 +199,5 @@