From 5501287032ce876c0c75cbaa84cdbac710565368 Mon Sep 17 00:00:00 2001 From: Mirco Kroon <23699979+mircokroon@users.noreply.github.com> Date: Sun, 20 Aug 2023 13:48:57 +0200 Subject: [PATCH 01/14] Fixed initial player position not being loaded correctly --- src/main/java/game/data/LevelData.java | 3 +++ 1 file changed, 3 insertions(+) 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) { From a648ed35de2bb03b6e0eee7df399fb58648b2913 Mon Sep 17 00:00:00 2001 From: Mirco Kroon <23699979+mircokroon@users.noreply.github.com> Date: Sun, 20 Aug 2023 15:16:24 +0200 Subject: [PATCH 02/14] Nicer looking player marker --- src/main/java/gui/GuiMap.java | 24 +++++----- src/main/java/gui/markers/MapMarker.java | 51 +++++++++++++++++++++ src/main/java/gui/markers/PlayerMarker.java | 16 +++++++ 3 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 src/main/java/gui/markers/MapMarker.java create mode 100644 src/main/java/gui/markers/PlayerMarker.java diff --git a/src/main/java/gui/GuiMap.java b/src/main/java/gui/GuiMap.java index c736b46e..0dd8c766 100644 --- a/src/main/java/gui/GuiMap.java +++ b/src/main/java/gui/GuiMap.java @@ -10,6 +10,8 @@ import game.data.chunk.Chunk; import game.data.dimension.Dimension; import game.data.entity.PlayerEntity; +import gui.markers.PlayerMarker; +import java.util.Arrays; import javafx.animation.AnimationTimer; import javafx.application.Platform; import javafx.beans.property.ReadOnlyDoubleProperty; @@ -24,6 +26,7 @@ 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; @@ -32,6 +35,8 @@ import java.lang.reflect.Method; import java.nio.IntBuffer; import java.util.Collection; +import javafx.scene.transform.Rotate; +import javafx.scene.transform.Translate; /** * Controller for the map scene. Contains a canvas for chunks which is redrawn only when required, and one for entities @@ -76,6 +81,8 @@ public class GuiMap { private ZoomBehaviour zoomBehaviour; + private PlayerMarker playerMarker = new PlayerMarker(); + @FXML void initialize() { this.zoomBehaviour = Config.smoothZooming() ? new SmoothZooming() : new SnapZooming(); @@ -391,22 +398,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, .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(Color.color(.1f, .1f, .1f)); + 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); } diff --git a/src/main/java/gui/markers/MapMarker.java b/src/main/java/gui/markers/MapMarker.java new file mode 100644 index 00000000..488fc847 --- /dev/null +++ b/src/main/java/gui/markers/MapMarker.java @@ -0,0 +1,51 @@ +package gui.markers; + +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 { + double[] xPoints; + double[] yPoints; + + public void transform(double offsetX, double offsetZ, double rotation, double size) { + double[] x = getShapePointsX(); + double[] y = getShapePointsY(); + + xPoints = new double[x.length]; + yPoints = new double[y.length]; + + Transform translate = new Translate(offsetX, offsetZ); + Transform rotate = new Rotate(rotation); + Transform scale = new Scale(size, size); + + for (int i = 0; i < x.length; i++) { + Point2D p = new Point2D(x[i], y[i]); + p = scale.transform(p); + p = rotate.transform(p); + p = translate.transform(p); + + xPoints[i] = p.getX(); + yPoints[i] = p.getY(); + } + } + + + 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/PlayerMarker.java b/src/main/java/gui/markers/PlayerMarker.java new file mode 100644 index 00000000..b28553eb --- /dev/null +++ b/src/main/java/gui/markers/PlayerMarker.java @@ -0,0 +1,16 @@ +package gui.markers; + +public class PlayerMarker extends MapMarker { + double[] xPoints = { 0, 8.5, 0, -8.5 }; + double[] yPoints = { 12, -8, -4, -8 }; + + @Override + double[] getShapePointsX() { + return xPoints; + } + + @Override + double[] getShapePointsY() { + return yPoints; + } +} From 913c62c8f7878e56b42d3cd54d577e50ed01f15f Mon Sep 17 00:00:00 2001 From: Mirco Kroon <23699979+mircokroon@users.noreply.github.com> Date: Sun, 20 Aug 2023 22:20:38 +0200 Subject: [PATCH 03/14] Added cave rendering mode --- .../game/data/chunk/ChunkImageFactory.java | 198 +++++++++++++++--- .../game/data/chunk/palette/SimpleColor.java | 6 +- src/main/java/gui/GuiMap.java | 4 +- src/main/java/gui/ImageMode.java | 14 ++ src/main/java/gui/RegionImageHandler.java | 130 +++++++++--- src/main/java/gui/RightClickMenu.java | 9 + 6 files changed, 298 insertions(+), 63 deletions(-) create mode 100644 src/main/java/gui/ImageMode.java diff --git a/src/main/java/game/data/chunk/ChunkImageFactory.java b/src/main/java/game/data/chunk/ChunkImageFactory.java index c968c466..e472b938 100644 --- a/src/main/java/game/data/chunk/ChunkImageFactory.java +++ b/src/main/java/game/data/chunk/ChunkImageFactory.java @@ -7,10 +7,13 @@ import game.data.coordinates.Coordinate3D; import game.data.coordinates.CoordinateDim2D; import game.data.dimension.Dimension; +import gui.ImageMode; import java.nio.IntBuffer; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.function.BiConsumer; import javafx.scene.image.Image; import javafx.scene.image.WritableImage; @@ -22,8 +25,9 @@ */ public class ChunkImageFactory { private final List registeredCallbacks = new ArrayList<>(2); - private final Runnable requestImage = this::requestImage;; - private BiConsumer onImageDone; + private final Runnable requestImage = this::requestImage; + ; + private BiConsumer, Boolean> onImageDone; private Runnable onSaved; private final Chunk c; @@ -53,7 +57,7 @@ public void initialise() { /** * Set handler for when the image has been created. */ - public void onComplete(BiConsumer onComplete) { + public void onComplete(BiConsumer, Boolean> onComplete) { this.onImageDone = onComplete; } @@ -84,6 +88,7 @@ public int heightAt(int x, int z) { /** * 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. */ @@ -127,12 +132,79 @@ public void requestImage() { registeredCallbacks.clear(); } - createImage(); + generateImages(); } - /** - * Generate and return the overview image for this chunk. - */ - private void createImage() { + + 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; + } + + private SimpleColor getColorCave(int x, int z) { + List caves = findCaves(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) { + int y = heightAt(x, z); + 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)); + + // 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(); @@ -144,40 +216,38 @@ private void createImage() { 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(); - } + SimpleColor color = isSurface ? getColorSurface(x, z) : 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 + 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. + */ + private void generateImages() { if (this.onImageDone != null) { - this.onImageDone.accept(i, c.isSaved()); + Map map = Map.of( + ImageMode.NORMAL, createImage(true), + ImageMode.CAVES, createImage(false) + ); + this.onImageDone.accept(map, c.isSaved()); } clearAdjacentChunks(); } @@ -286,7 +356,7 @@ private int computeHeight(int x, int z) { public void updateHeight(Coordinate3D coords) { if (coords.getY() >= heightAt(coords.getX(), coords.getZ())) { recomputeHeight(coords.getX(), coords.getZ()); - createImage(); + generateImages(); } } @@ -302,7 +372,7 @@ public void recomputeHeights(Collection toUpdate) { } } if (hasChanged) { - createImage(); + generateImages(); } } @@ -321,3 +391,71 @@ public String toString() { '}'; } } + + +final 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 + ']'; + } + + 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); + } + + + private double getRatio(double min, double max, double val) { + if (val > max) { return 1.0; } + if ( val < min) { return 0.0; } + return (val - min) / (max - min); + } +} \ No newline at end of file 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/gui/GuiMap.java b/src/main/java/gui/GuiMap.java index 0dd8c766..1d881ffd 100644 --- a/src/main/java/gui/GuiMap.java +++ b/src/main/java/gui/GuiMap.java @@ -314,7 +314,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(); } @@ -398,7 +398,7 @@ private void drawPlayer(GraphicsContext graphics) { double playerX = ((playerPos.getX() - bounds.getMinX()) / blocksPerPixel); double playerZ = ((playerPos.getZ() - bounds.getMinZ()) / blocksPerPixel); - playerMarker.transform(playerX, playerZ, this.playerRotation, .7); + playerMarker.transform(playerX, playerZ, this.playerRotation, 0.7); // marker graphics.setFillRule(FillRule.NON_ZERO); diff --git a/src/main/java/gui/ImageMode.java b/src/main/java/gui/ImageMode.java new file mode 100644 index 00000000..c9e4319e --- /dev/null +++ b/src/main/java/gui/ImageMode.java @@ -0,0 +1,14 @@ +package gui; + +public enum ImageMode { + NORMAL(""), CAVES("caves"); + + final String path; + ImageMode(String path) { + this.path = path; + } + + + public String path() { return path; } +} + diff --git a/src/main/java/gui/RegionImageHandler.java b/src/main/java/gui/RegionImageHandler.java index 4e656e4d..fbff22ba 100644 --- a/src/main/java/gui/RegionImageHandler.java +++ b/src/main/java/gui/RegionImageHandler.java @@ -7,6 +7,7 @@ import game.data.coordinates.Coordinate2D; import game.data.coordinates.CoordinateDim2D; import game.data.dimension.Dimension; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -25,11 +26,12 @@ */ public class RegionImageHandler { private static final String CACHE_PATH = "image-cache"; - private Map regions; + private Map regions; private Dimension activeDimension; private boolean isSaving = false; private final ScheduledExecutorService saveService; + public static ImageMode imageMode = ImageMode.NORMAL; public RegionImageHandler() { this.regions = new ConcurrentHashMap<>(); @@ -46,33 +48,36 @@ public void clear() { attemptQuiet(() -> FileUtils.deleteDirectory(Paths.get(Config.getWorldOutputDir(), CACHE_PATH).toFile())); } - public void drawChunk(CoordinateDim2D coordinate, Image chunkImage, Boolean isSaved) { + public void drawChunk(CoordinateDim2D coordinate, Map imageMap, Boolean isSaved) { if (!coordinate.getDimension().equals(activeDimension)) { return; } Coordinate2D region = coordinate.chunkToRegion(); - RegionImage image = regions.computeIfAbsent(region, (coordinate2D -> loadRegion(coordinate))); + RegionImages images = regions.computeIfAbsent(region, (coordinate2D -> loadRegion(coordinate))); Coordinate2D local = coordinate.toRegionLocal(); - image.drawChunk(local, chunkImage); - setChunkState(image, local, ChunkImageState.isSaved(isSaved)); + imageMap.forEach((mode, image) -> { + images.getImage(mode).drawChunk(local, image); + }); + + setChunkState(images, local, ChunkImageState.isSaved(isSaved)); } public void setChunkState(Coordinate2D coords, ChunkImageState state) { Coordinate2D region = coords.chunkToRegion(); - RegionImage image = regions.get(region); - if (image == null) { + RegionImages images = regions.get(region); + if (images == null) { return; } - setChunkState(image, coords.toRegionLocal(), state); + setChunkState(images, coords.toRegionLocal(), state); } - private void setChunkState(RegionImage image, Coordinate2D local, ChunkImageState state) { + private void setChunkState(RegionImages image, Coordinate2D local, ChunkImageState state) { image.colourChunk(local, state.getColor()); } @@ -81,11 +86,11 @@ public void markChunkSaved(CoordinateDim2D coordinate) { setChunkState(coordinate, ChunkImageState.SAVED); } - private RegionImage loadRegion(Coordinate2D coordinate) { - return RegionImage.of(dimensionPath(this.activeDimension), coordinate); + private RegionImages loadRegion(Coordinate2D coordinate) { + return RegionImages.of(activeDimension, coordinate); } - private void save(Map regions, Dimension dim) { + private void save(Map regions, Dimension dim) { // if shutdown is called, wait for saving to complete if (isSaving) { if (saveService != null) { @@ -95,9 +100,12 @@ private void save(Map regions, Dimension dim) { } isSaving = true; - attempt(() -> Files.createDirectories(dimensionPath(dim))); + for (ImageMode mode : ImageMode.values()) { + attempt(() -> Files.createDirectories(dimensionPath(dim, mode))); + } + regions.forEach((coordinate, image) -> { - attempt(() -> image.save(dimensionPath(dim), coordinate)); + attempt(() -> image.save(dim, coordinate)); }); isSaving = false; @@ -115,24 +123,29 @@ private void unload() { * Searches for all region files in a directory to load them in. */ private void load() { - Map regionMap = regions; + Map regionMap = regions; new Thread(() -> attemptQuiet(() -> { - Files.walk(dimensionPath(this.activeDimension), 1).limit(3200) - .forEach(image -> attempt(() -> { - if (!image.toString().toLowerCase().endsWith("png")) { - return; - } + for (ImageMode mode : ImageMode.values()) { + Files.walk(dimensionPath(this.activeDimension, mode), 1) + .limit(3200) + .forEach(image -> attempt(() -> load(regionMap, mode, image))); + } + })).start(); + } - String[] parts = image.getFileName().toString().split("\\."); + private void load(Map regionMap, ImageMode mode, Path image) { + if (!image.toString().toLowerCase().endsWith("png")) { + return; + } - int x = Integer.parseInt(parts[1]); - int z = Integer.parseInt(parts[2]); - Coordinate2D regionCoordinate = new Coordinate2D(x, z); + String[] parts = image.getFileName().toString().split("\\."); - regionMap.put(regionCoordinate, RegionImage.of(image.toFile())); - })); - })).start(); + int x = Integer.parseInt(parts[1]); + int z = Integer.parseInt(parts[2]); + Coordinate2D regionCoordinate = new Coordinate2D(x, z); + + regionMap.computeIfAbsent(regionCoordinate, k -> new RegionImages()).set(mode, image); } public void setDimension(Dimension dimension) { @@ -149,13 +162,20 @@ public void setDimension(Dimension dimension) { load(); } - private static Path dimensionPath(Dimension dim) { + 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()); + } + public void drawAll(Bounds bounds, BiConsumer drawRegion) { - regions.forEach((coordinate, image) -> { + regions.forEach((coordinate, images) -> { if (bounds.overlaps(coordinate)) { + RegionImage image = images.getImage(imageMode); + if (image == null) { return; } + drawRegion.accept(coordinate, image.getImage()); drawRegion.accept(coordinate, image.getChunkOverlay()); } @@ -173,7 +193,57 @@ public void shutdown() { } public void resetRegion(Coordinate2D region) { - attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension), region))); + attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension, ImageMode.NORMAL), region))); + attemptQuiet(() -> FileUtils.delete(RegionImage.getFile(dimensionPath(this.activeDimension, ImageMode.CAVES), region))); regions.remove(region); } } + +class RegionImages { + RegionImage normal; + RegionImage caves; + + public RegionImages(RegionImage normal, RegionImage caves) { + this.normal = normal; + this.caves = caves; + } + + public RegionImages() { + normal = new RegionImage(); + caves = new RegionImage(); + } + + public static RegionImages of(Dimension dimension, Coordinate2D coordinate) { + RegionImage normal = RegionImage.of(RegionImageHandler.dimensionPath(dimension, ImageMode.NORMAL).toFile()); + RegionImage caves = RegionImage.of(RegionImageHandler.dimensionPath(dimension, ImageMode.CAVES).toFile()); + + 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(Dimension dim, Coordinate2D coordinate) throws IOException { + normal.save(RegionImageHandler.dimensionPath(dim, ImageMode.NORMAL), coordinate); + caves.save(RegionImageHandler.dimensionPath(dim, ImageMode.CAVES), coordinate); + } + + public void set(ImageMode mode, Path image) { + switch (mode) { + case NORMAL -> normal = RegionImage.of(image.toFile()); + case CAVES -> caves = RegionImage.of(image.toFile()); + }; + } +} + diff --git a/src/main/java/gui/RightClickMenu.java b/src/main/java/gui/RightClickMenu.java index a8c1011f..4a36c653 100644 --- a/src/main/java/gui/RightClickMenu.java +++ b/src/main/java/gui/RightClickMenu.java @@ -144,6 +144,15 @@ private void addDevOptions(List menu, GuiMap handler) { menu.add(construct("Print chunk events", e -> { Chunk.printEventLog(handler.getCursorCoordinates().globalToChunk().addDimension(Dimension.OVERWORLD)); })); + + menu.add(construct("Set to SURFACE mode", e -> { + RegionImageHandler.imageMode = ImageMode.NORMAL; + })); + + menu.add(construct("Set to CAVE mode", e -> { + RegionImageHandler.imageMode = ImageMode.CAVES; + })); + } private MenuItem construct(String name, HandleError handler) { From 0d355febfc34fde52d94aeef420ad52cc762d9d3 Mon Sep 17 00:00:00 2001 From: Mirco Kroon <23699979+mircokroon@users.noreply.github.com> Date: Mon, 21 Aug 2023 23:31:45 +0200 Subject: [PATCH 04/14] Added switching between rendering modes --- src/main/java/config/Config.java | 7 + src/main/java/game/data/WorldManager.java | 30 +- src/main/java/game/data/chunk/Cave.java | 71 ++ src/main/java/game/data/chunk/Chunk.java | 17 +- .../game/data/chunk/ChunkHeightHandler.java | 155 ++++ .../game/data/chunk/ChunkImageFactory.java | 739 +++++++----------- .../game/data/chunk/version/Chunk_1_16.java | 2 +- .../java/game/data/dimension/Dimension.java | 4 + src/main/java/gui/GuiMap.java | 8 +- src/main/java/gui/GuiSettings.java | 30 +- src/main/java/gui/RightClickMenu.java | 35 +- src/main/java/gui/{ => images}/ImageMode.java | 8 +- .../java/gui/{ => images}/RegionImage.java | 3 +- .../gui/{ => images}/RegionImageHandler.java | 34 +- src/main/resources/ui/Settings.fxml | 115 +-- 15 files changed, 722 insertions(+), 536 deletions(-) create mode 100644 src/main/java/game/data/chunk/Cave.java create mode 100644 src/main/java/game/data/chunk/ChunkHeightHandler.java rename src/main/java/gui/{ => images}/ImageMode.java (56%) rename src/main/java/gui/{ => images}/RegionImage.java (98%) rename src/main/java/gui/{ => images}/RegionImageHandler.java (89%) diff --git a/src/main/java/config/Config.java b/src/main/java/config/Config.java index 3286bdf0..a2a0ae98 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 = true; + // 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/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 e472b938..521fa01b 100644 --- a/src/main/java/game/data/chunk/ChunkImageFactory.java +++ b/src/main/java/game/data/chunk/ChunkImageFactory.java @@ -1,461 +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 gui.ImageMode; -import java.nio.IntBuffer; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Objects; -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, Boolean> 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, 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); - } - } - - 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(); - } - - generateImages(); - } - - 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; - } - - private SimpleColor getColorCave(int x, int z) { - List caves = findCaves(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) { - int y = heightAt(x, z); - 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)); - - // 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; - - 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) : 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. - */ - private 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 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()); - generateImages(); - } - } - - /** - * 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) { - generateImages(); - } - } - - 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 + - '}'; - } -} - - -final 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 + ']'; - } - - 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); - } - - - private double getRatio(double min, double max, double val) { - if (val > max) { return 1.0; } - if ( val < min) { return 0.0; } - return (val - min) / (max - min); - } -} \ No newline at end of file +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) { + 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/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/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 1d881ffd..420cc839 100644 --- a/src/main/java/gui/GuiMap.java +++ b/src/main/java/gui/GuiMap.java @@ -10,8 +10,8 @@ import game.data.chunk.Chunk; import game.data.dimension.Dimension; import game.data.entity.PlayerEntity; +import gui.images.RegionImageHandler; import gui.markers.PlayerMarker; -import java.util.Arrays; import javafx.animation.AnimationTimer; import javafx.application.Platform; import javafx.beans.property.ReadOnlyDoubleProperty; @@ -35,8 +35,6 @@ import java.lang.reflect.Method; import java.nio.IntBuffer; import java.util.Collection; -import javafx.scene.transform.Rotate; -import javafx.scene.transform.Translate; /** * Controller for the map scene. Contains a canvas for chunks which is redrawn only when required, and one for entities @@ -98,6 +96,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) { 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/RightClickMenu.java b/src/main/java/gui/RightClickMenu.java index 4a36c653..3df333e6 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 -> { @@ -144,15 +162,12 @@ private void addDevOptions(List menu, GuiMap handler) { menu.add(construct("Print chunk events", e -> { Chunk.printEventLog(handler.getCursorCoordinates().globalToChunk().addDimension(Dimension.OVERWORLD)); })); + } - menu.add(construct("Set to SURFACE mode", e -> { - RegionImageHandler.imageMode = ImageMode.NORMAL; - })); - - menu.add(construct("Set to CAVE mode", e -> { - RegionImageHandler.imageMode = ImageMode.CAVES; - })); - + 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) { diff --git a/src/main/java/gui/ImageMode.java b/src/main/java/gui/images/ImageMode.java similarity index 56% rename from src/main/java/gui/ImageMode.java rename to src/main/java/gui/images/ImageMode.java index c9e4319e..adb5223c 100644 --- a/src/main/java/gui/ImageMode.java +++ b/src/main/java/gui/images/ImageMode.java @@ -1,4 +1,4 @@ -package gui; +package gui.images; public enum ImageMode { NORMAL(""), CAVES("caves"); @@ -8,6 +8,12 @@ public enum ImageMode { 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/RegionImage.java b/src/main/java/gui/images/RegionImage.java similarity index 98% rename from src/main/java/gui/RegionImage.java rename to src/main/java/gui/images/RegionImage.java index b0d2c0a7..aa836b8a 100644 --- a/src/main/java/gui/RegionImage.java +++ b/src/main/java/gui/images/RegionImage.java @@ -1,9 +1,10 @@ -package gui; +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.io.File; import java.io.IOException; import java.nio.ByteBuffer; diff --git a/src/main/java/gui/RegionImageHandler.java b/src/main/java/gui/images/RegionImageHandler.java similarity index 89% rename from src/main/java/gui/RegionImageHandler.java rename to src/main/java/gui/images/RegionImageHandler.java index fbff22ba..914f1fb3 100644 --- a/src/main/java/gui/RegionImageHandler.java +++ b/src/main/java/gui/images/RegionImageHandler.java @@ -1,12 +1,15 @@ -package gui; +package gui.images; 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; @@ -31,7 +34,9 @@ public class RegionImageHandler { private boolean isSaving = false; private final ScheduledExecutorService saveService; - public static ImageMode imageMode = ImageMode.NORMAL; + private static ImageMode overrideMode; + private ImageMode imageMode = ImageMode.NORMAL; + public RegionImageHandler() { this.regions = new ConcurrentHashMap<>(); @@ -43,6 +48,14 @@ public RegionImageHandler() { saveService.scheduleWithFixedDelay(this::save, 20, 20, TimeUnit.SECONDS); } + 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())); @@ -170,7 +183,24 @@ 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, BiConsumer drawRegion) { + updateRenderMode(); + regions.forEach((coordinate, images) -> { if (bounds.overlaps(coordinate)) { RegionImage image = images.getImage(imageMode); 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 @@