diff --git a/chunky/src/java/se/llbit/chunky/map/MapTile.java b/chunky/src/java/se/llbit/chunky/map/MapTile.java index 2f00a9991a..184536f8e5 100644 --- a/chunky/src/java/se/llbit/chunky/map/MapTile.java +++ b/chunky/src/java/se/llbit/chunky/map/MapTile.java @@ -71,8 +71,9 @@ public void draw(MapBuffer buffer, WorldMapLoader mapLoader, ChunkView view, } } else { RegionPosition regionPos = new RegionPosition(pos.x, pos.z); // intentionally don't convert, this position represented a region already. - boolean isValid = mapLoader.getWorld().currentDimension().regionExistsWithinRange(regionPos, view.yMin, view.yMax); - Region region = mapLoader.getWorld().currentDimension().getRegionWithinRange(regionPos, view.yMin, view.yMax); + HeightRange heightRange = new HeightRange(view.yMin, view.yMax); + boolean isValid = mapLoader.getWorld().currentDimension().hasRegionWithinRange(regionPos, heightRange); + Region region = mapLoader.getWorld().currentDimension().getRegionWithinRange(regionPos, heightRange); int pixelOffset = 0; for (int z = 0; z < 32; ++z) { for (int x = 0; x < 32; ++x) { diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index 1119340ea5..93b0c915cf 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -16,15 +16,20 @@ */ package se.llbit.chunky.map; +import java.util.Optional; import java.util.function.BiConsumer; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.chunky.world.region.RegionChangeWatcher; import se.llbit.chunky.world.region.RegionParser; import se.llbit.chunky.world.region.RegionQueue; import se.llbit.chunky.world.listeners.ChunkTopographyListener; +import se.llbit.chunky.world.worldformat.WorldFormats; +import se.llbit.log.Log; +import se.llbit.util.annotation.Nullable; import java.io.File; import java.util.ArrayList; @@ -64,28 +69,75 @@ public WorldMapLoader(ChunkyFxController controller, MapView mapView) { topographyUpdater.start(); } + public void loadWorldFromDirectory(@Nullable File worldLocation, @Nullable String worldFormatId) { + if (worldLocation != null) { + if (worldFormatId == null || worldFormatId.isEmpty()) { + worldFormatId = JavaWorldFormat.ID; + } + Optional info = WorldFormats.getWorldFormat(worldFormatId) + .flatMap(format -> format.getWorldInfo(worldLocation.toPath())) // attempt to get the given format + .or(() -> WorldFormats.getInfos(worldLocation.toPath()).stream().findFirst()); // get any format + info.ifPresent(this::loadWorld); + return; + } + setWorld(EmptyWorld.INSTANCE); + } + /** - * This is called when a new world is loaded + * Load the world referred to by the {@link World.Info} + * + * @return The loaded world. May be {@link EmptyWorld} if loading failed. */ - public void loadWorld(File worldDir) { - if (World.isWorldDir(worldDir)) { - if (world != null) { - world.currentDimension().removeChunkTopographyListener(this); - } - boolean isSameWorld = !(world instanceof EmptyWorld) && worldDir.equals(world.getWorldDirectory()); - World newWorld = World.loadWorld(worldDir, currentDimensionId, World.LoggedWarnings.NORMAL); - newWorld.currentDimension().addChunkTopographyListener(this); - synchronized (this) { - world = newWorld; - updateRegionChangeWatcher(newWorld.currentDimension()); - - File newWorldDir = world.getWorldDirectory(); - if (newWorldDir != null && !newWorldDir.equals(PersistentSettings.getLastWorld())) { - PersistentSettings.setLastWorld(newWorldDir); - } + public World loadWorld(World.Info info) { + World newWorld = WorldFormats.createWorld(info); + + Optional dimensionToLoad = Optional.of(world.currentDimension()) + .map(Dimension::getDimensionId) + .filter(dimension -> newWorld.getAvailableDimensions().contains(dimension)) + .or(newWorld::getDefaultDimension) + .or(() -> newWorld.getAvailableDimensions().stream().findFirst()); + + if (dimensionToLoad.isPresent()) { + newWorld.loadDimension(dimensionToLoad.get()); + } else { + Log.infof("No dimension loaded for world %s", info.toString()); + } + + setWorld(newWorld); + return this.world; + } + + /** + * Sets the map view world. + *

This is intended to be called with worlds with a dimension already loaded, as it will not trigger dimension + * loading.

+ * + * @param newWorld The world to set + */ + public void setWorld(World newWorld) { + if (this.world != null) { + this.world.currentDimension().removeChunkTopographyListener(this); + } + + boolean isSameWorld = !(this.world instanceof EmptyWorld) && newWorld.getInfo().path().equals(this.world.getInfo().path()); + + Dimension loadedDim = newWorld.currentDimension(); + if (loadedDim == EmptyWorld.INSTANCE.currentDimension()) { + Log.warn("Map view world was set but it has no dimension!"); + } + + loadedDim.addChunkTopographyListener(this); + synchronized (this) { + this.world = newWorld; + updateRegionChangeWatcher(loadedDim); + + File newWorldDir = this.world.getInfo().path().toFile(); + if (!newWorldDir.equals(PersistentSettings.getLastWorld())) { + PersistentSettings.setLastWorld(newWorldDir); } - worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); + PersistentSettings.setLastWorldFormat(newWorld.getInfo().worldFormat().getId()); } + worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); } /** @@ -151,14 +203,12 @@ public void regionUpdated(RegionPosition region) { public void reloadWorld() { topographyUpdater.clearQueue(); world.currentDimension().removeChunkTopographyListener(this); - World newWorld = World.loadWorld(world.getWorldDirectory(), currentDimensionId, - World.LoggedWarnings.NORMAL); - newWorld.currentDimension().addChunkTopographyListener(this); + world.loadDimension(currentDimensionId); + world.currentDimension().addChunkTopographyListener(this); synchronized (this) { - world = newWorld; - updateRegionChangeWatcher(newWorld.currentDimension()); + updateRegionChangeWatcher(world.currentDimension()); } - worldLoadListeners.forEach(listener -> listener.accept(newWorld, true)); + worldLoadListeners.forEach(listener -> listener.accept(world, true)); viewUpdated(mapView.getMapView()); // update visible chunks immediately } @@ -173,6 +223,8 @@ private void updateRegionChangeWatcher(Dimension dimension) { /** * Set the current dimension. + * + * @param value Must be a valid dimension see {@link World#getAvailableDimensions()} */ public void setDimension(Dimension.Identifier value) { if (value != currentDimensionId) { diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index bc4bc6bf61..c4b923dffc 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -53,7 +53,9 @@ import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.java.JavaWorldFormat; +import se.llbit.chunky.world.region.Region; +import se.llbit.chunky.world.worldformat.WorldFormats; import se.llbit.json.*; import se.llbit.log.Log; import se.llbit.math.*; @@ -67,6 +69,7 @@ import se.llbit.util.mojangapi.MinecraftProfile; import java.io.*; +import java.nio.file.Path; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.ExecutionException; @@ -192,6 +195,7 @@ public class Scene implements JsonSerializable { */ protected int rayDepth = PersistentSettings.getRayDepthDefault(); protected String worldPath = ""; + protected String worldFormat = JavaWorldFormat.ID; protected Dimension.Identifier worldDimension = Dimension.Identifier.OVERWORLD; protected RenderMode mode = RenderMode.PREVIEW; protected int dumpFrequency = DEFAULT_DUMP_FREQUENCY; @@ -404,6 +408,7 @@ public synchronized void copyState(Scene other, boolean copyChunks) { if (copyChunks) { loadedWorld = other.loadedWorld; worldPath = other.worldPath; + worldFormat = other.worldFormat; worldDimension = other.worldDimension; // The octree reference is overwritten to save time. @@ -546,11 +551,17 @@ public synchronized void loadScene(RenderContext context, String sceneName, Task loadedWorld = EmptyWorld.INSTANCE; if (!worldPath.isEmpty()) { - File worldDirectory = new File(worldPath); - if (World.isWorldDir(worldDirectory)) { - loadedWorld = World.loadWorld(worldDirectory, worldDimension, World.LoggedWarnings.NORMAL); - } else { - Log.info("Could not load world: " + worldPath); + Path worldDirectory = Path.of(worldPath); + Optional info = WorldFormats.getWorldFormat(worldFormat) // only try to load the world as its known world format. + .flatMap(format -> format.getWorldInfo(worldDirectory)); + + if (info.isPresent()) { + World newWorld = WorldFormats.createWorld(info.get()); + loadedWorld = newWorld; + loadedWorld.loadDimension(this.worldDimension); + if (newWorld == EmptyWorld.INSTANCE) { + Log.info("Could not load world: " + worldPath); + } } } @@ -767,7 +778,7 @@ public synchronized void reloadChunks(TaskTracker taskTracker) { Log.warn("Can not reload chunks for scene - world directory not found!"); return; } - loadedWorld = World.loadWorld(loadedWorld.getWorldDirectory(), worldDimension, World.LoggedWarnings.NORMAL); + loadedWorld.loadDimension(worldDimension); loadChunks(taskTracker, loadedWorld, ChunkSelectionTracker.selectionByRegion(chunks)); refresh(); } @@ -803,7 +814,8 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map>>> createRegionDataFuture = (regionPosition, chunkDataArray) -> executor.submit(() -> { List chunkPositionsToLoad = chunksToLoadByRegion.get(regionPosition); @@ -2670,6 +2682,7 @@ public void setUseCustomWaterColor(boolean value) { // Save world info. JsonObject world = new JsonObject(); world.add("path", worldPath); + world.add("worldType", loadedWorld.getInfo().worldFormat().getId()); world.add("dimension", worldDimension.getNamespacedName()); json.add("world", world); } @@ -2992,7 +3005,7 @@ else if(waterShader.equals("SIMPLEX")) if (json.get("world").isObject()) { JsonObject world = json.get("world").object(); worldPath = world.get("path").stringValue(worldPath); - + worldFormat = world.get("worldFormat").stringValue(JavaWorldFormat.ID); if (world.get("dimension") instanceof JsonString) { // dimension already is a string (or undefined) worldDimension = Dimension.Identifier.fromNamespacedName(world.get("dimension").stringValue("minecraft:overworld")); diff --git a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java index 270374e554..7619c60f3f 100644 --- a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java +++ b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java @@ -19,22 +19,16 @@ import javafx.application.Platform; import javafx.geometry.Point2D; -import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.*; -import javafx.scene.control.Button; -import javafx.scene.control.Dialog; import javafx.scene.control.MenuItem; -import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; -import javafx.scene.layout.Border; -import javafx.scene.layout.GridPane; import javafx.stage.PopupWindow; import se.llbit.chunky.map.MapBuffer; import se.llbit.chunky.map.MapView; @@ -45,11 +39,10 @@ import se.llbit.chunky.renderer.scene.SceneManager; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.ui.dialogs.SelectChunksInRadiusDialog; -import se.llbit.chunky.ui.elements.TextFieldLabelWrapper; import se.llbit.chunky.world.*; import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.listeners.ChunkUpdateListener; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; import se.llbit.log.Log; import se.llbit.math.*; @@ -57,7 +50,6 @@ import java.io.File; import java.io.IOException; import java.util.Collection; -import java.util.Optional; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -227,8 +219,8 @@ public ChunkMap(WorldMapLoader loader, ChunkyFxController controller, if (view.chunkScale >= 16) { int minChunkX = region.x << 5; int minChunkZ = region.z << 5; - for (int chunkX = minChunkX; chunkX < minChunkX + MCRegion.CHUNKS_X; chunkX++) { - for (int chunkZ = minChunkZ; chunkZ < minChunkZ + MCRegion.CHUNKS_Z; chunkZ++) { + for (int chunkX = minChunkX; chunkX < minChunkX + Region.CHUNKS_X; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ < minChunkZ + Region.CHUNKS_Z; chunkZ++) { mapBuffer.drawTile(mapLoader, new ChunkPosition(chunkX, chunkZ), chunkSelection); } } @@ -596,18 +588,16 @@ private void drawPlayers(GraphicsContext gc) { World world = mapLoader.getWorld(); double blockScale = mapView.scale / 16.; for (PlayerEntityData player : world.currentDimension().getPlayerPositions()) { - if (player.dimension.equals(world.currentDimension().getDimensionId())) { - int px = (int) QuickMath.floor(player.x * blockScale); - int py = (int) QuickMath.floor(player.y); - int pz = (int) QuickMath.floor(player.z * blockScale); - int ppx = px - (int) QuickMath.floor(mapView.x0 * mapView.scale); - int ppy = pz - (int) QuickMath.floor(mapView.z0 * mapView.scale); - int pw = (int) QuickMath.max(16, QuickMath.min(32, blockScale * 4)); - ppx = Math.min(mapView.width - pw, Math.max(0, ppx - pw / 2)); - ppy = Math.min(mapView.height - pw, Math.max(0, ppy - pw / 2)); - - gc.drawImage(Icon.player.fxImage(), ppx, ppy, pw, pw); - } + int px = (int) QuickMath.floor(player.x * blockScale); + int py = (int) QuickMath.floor(player.y); + int pz = (int) QuickMath.floor(player.z * blockScale); + int ppx = px - (int) QuickMath.floor(mapView.x0 * mapView.scale); + int ppy = pz - (int) QuickMath.floor(mapView.z0 * mapView.scale); + int pw = (int) QuickMath.max(16, QuickMath.min(32, blockScale * 4)); + ppx = Math.min(mapView.width - pw, Math.max(0, ppx - pw / 2)); + ppy = Math.min(mapView.height - pw, Math.max(0, ppy - pw / 2)); + + gc.drawImage(Icon.player.fxImage(), ppx, ppy, pw, pw); } } diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java index c93aa97ffd..cb5cc071bb 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java @@ -23,12 +23,7 @@ import java.nio.file.Path; import java.text.DecimalFormat; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.IdentityHashMap; -import java.util.Map; -import java.util.Optional; -import java.util.ResourceBundle; +import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; @@ -286,7 +281,7 @@ public void exportMapView() { fileChooser.setTitle("Export PNG"); fileChooser .getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG image", "*.png")); - mapLoader.withWorld(world -> fileChooser.setInitialFileName(world.levelName() + ".png")); + mapLoader.withWorld(world -> fileChooser.setInitialFileName(world.getInfo().name() + ".png")); if (prevPngDir != null) { fileChooser.setInitialDirectory(prevPngDir.toFile()); } @@ -326,7 +321,7 @@ public void exportMapView() { World newWorld = scene.getWorld(); World currentWorld = mapLoader.getWorld(); boolean isSameWorld = currentWorld != EmptyWorld.INSTANCE && - currentWorld.getWorldDirectory().equals(newWorld.getWorldDirectory()); + currentWorld.getInfo().isSameWorld(newWorld.getInfo()); if (isSameWorld) { getChunkSelection().setSelection(chunky.getSceneManager().getScene().getChunks()); @@ -340,7 +335,7 @@ public void exportMapView() { "This scene shows a different world than the one that is currently loaded. Do you want to load the world of this scene?"); Dialogs.stayOnTop(loadWorldConfirm); if (loadWorldConfirm.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.YES) { - mapLoader.loadWorld(newWorld.getWorldDirectory()); + mapLoader.setWorld(newWorld); getChunkSelection().setSelection(chunky.getSceneManager().getScene().getChunks()); } } @@ -459,25 +454,20 @@ public File getSceneFile(String fileName) { } if (!reloaded) { ignoreYUpdate.set(true); - if (mapLoader.getWorld().getVersionId() >= World.VERSION_21W06A) { - yMin.setRange(-64, 320); - yMin.set(-64); - yMax.setRange(-64, 320); - yMax.set(320); - mapView.setYMinMax(-64, 320); - } else { - yMin.setRange(0, 256); - yMin.set(0); - yMax.setRange(0, 256); - yMax.set(256); - mapView.setYMinMax(0, 256); - } + HeightRange heightRange = mapLoader.getWorld().currentDimension().heightRange(); + int min = heightRange.min(); + int max = heightRange.max(); + yMin.setRange(min, max); + yMin.set(min); + yMax.setRange(min, max); + yMax.set(max); + mapView.setYMinMax(min, max); yMin.getStyleClass().removeAll("invalid"); yMax.getStyleClass().removeAll("invalid"); ignoreYUpdate.set(false); } map.redrawMap(); - mapName.setText(world.levelName()); + mapName.setText(world.getInfo().name()); showWorldMap(); }); }); @@ -656,14 +646,10 @@ public File getSceneFile(String fileName) { mapOverlay.setOnKeyPressed(map::onKeyPressed); mapOverlay.setOnKeyReleased(map::onKeyReleased); - mapLoader.loadWorld(PersistentSettings.getLastWorld()); - if (mapLoader.getWorld().getVersionId() >= World.VERSION_21W06A) { - mapView.setYMin(-64); - mapView.setYMax(320); - } else { - mapView.setYMin(0); - mapView.setYMax(256); - } + mapLoader.loadWorldFromDirectory(PersistentSettings.getLastWorld(), PersistentSettings.getLastWorldFormat()); + HeightRange heightRange = mapLoader.getWorld().currentDimension().heightRange(); + mapView.setYMin(heightRange.min()); + mapView.setYMax(heightRange.max()); canvas = new RenderCanvasFx(this, chunky.getSceneManager().getScene(), chunky.getRenderController().getRenderManager()); diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java index 5705e9e625..6df657d0bb 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java @@ -31,11 +31,12 @@ import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.resources.MinecraftFinder; import se.llbit.chunky.resources.ResourcePackLoader; -import se.llbit.chunky.resources.TexturePackLoader; import se.llbit.chunky.ui.TableSortConfigSerializer; +import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.World; +import se.llbit.chunky.world.worldformat.WorldFormats; import se.llbit.fxutil.Dialogs; import se.llbit.json.JsonArray; import se.llbit.log.Log; @@ -48,17 +49,17 @@ public class WorldChooserController implements Initializable { @FXML private Label statusLabel; - @FXML private TableView worldTbl; + @FXML private TableView worldTbl; - @FXML private TableColumn worldNameCol; + @FXML private TableColumn worldNameCol; - @FXML private TableColumn worldDirCol; + @FXML private TableColumn worldDirCol; - @FXML private TableColumn gameModeCol; + @FXML private TableColumn gameModeCol; - @FXML private TableColumn seedCol; + @FXML private TableColumn seedCol; - @FXML public TableColumn modifiedCol; + @FXML public TableColumn modifiedCol; @FXML private Button changeWorldDirBtn; @@ -69,15 +70,15 @@ public class WorldChooserController implements Initializable { @Override public void initialize(URL location, ResourceBundle resources) { worldNameCol - .setCellValueFactory(data -> new ReadOnlyStringWrapper(data.getValue().levelName())); + .setCellValueFactory(data -> new ReadOnlyStringWrapper(data.getValue().name())); worldDirCol.setCellValueFactory( - data -> new ReadOnlyStringWrapper(data.getValue().getWorldDirectory().getName())); + data -> new ReadOnlyStringWrapper(data.getValue().path().getFileName().toString())); gameModeCol.setCellValueFactory(data -> new ReadOnlyStringWrapper(data.getValue().gameMode())); - seedCol.setCellValueFactory(data -> new ReadOnlyLongWrapper(data.getValue().getSeed())); + seedCol.setCellValueFactory(data -> new ReadOnlyLongWrapper(data.getValue().seed())); DateFormat localeFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT); - modifiedCol.setCellValueFactory(data -> new ReadOnlyObjectWrapper<>(data.getValue().getLastModified())); - modifiedCol.setCellFactory(col -> new TableCell() { + modifiedCol.setCellValueFactory(data -> new ReadOnlyObjectWrapper<>(new Date(data.getValue().lastModified()))); + modifiedCol.setCellFactory(col -> new TableCell<>() { public void updateItem(Date item, boolean empty) { if (item == this.getItem()) return; super.updateItem(item, empty); @@ -96,7 +97,7 @@ public void setStage(Stage stage) { */ public void populate(WorldMapLoader mapLoader) { worldTbl.setRowFactory(tbl -> { - TableRow row = new TableRow<>(); + TableRow row = new TableRow<>(); row.setOnMouseClicked(e -> { if (e.getClickCount() == 2 && !row.isEmpty()) { this.loadWorld(row.getItem(), mapLoader); @@ -135,7 +136,12 @@ public void populate(WorldMapLoader mapLoader) { File directory = chooser.showDialog(stage); if (directory != null) { if (directory.isDirectory()) { - this.loadWorld(World.loadWorld(directory, mapLoader.getDimension(), World.LoggedWarnings.NORMAL), mapLoader); + Optional info = WorldFormats.getInfos(directory.toPath()).stream().findFirst(); // TODO: could ask which world format to load as + if (info.isPresent()) { + this.loadWorld(info.get(), mapLoader); + } else { + mapLoader.setWorld(EmptyWorld.INSTANCE); + } stage.close(); } else { Log.warn("Non-directory selected."); @@ -150,8 +156,9 @@ public void populate(WorldMapLoader mapLoader) { }); } - private void loadWorld(World world, WorldMapLoader mapLoader) { - world.getResourcePack() + private void loadWorld(World.Info info, WorldMapLoader mapLoader) { + mapLoader.loadWorld(info) + .getResourcePack() .ifPresent(worldResourcePack -> { List currentlyLoadedPacks = new ArrayList<>(ResourcePackLoader.getLoadedResourcePacks()); @@ -161,7 +168,7 @@ private void loadWorld(World world, WorldMapLoader mapLoader) { loadTexturesConfirm.getButtonTypes().addAll(ButtonType.YES, ButtonType.NO); loadTexturesConfirm.setTitle("Bundled resource pack"); loadTexturesConfirm.setContentText( - "The world \"" + world.levelName() + "\" contains a resource pack. Do you want to load it now?"); + "The world \"" + info.name() + "\" contains a resource pack. Do you want to load it now?"); Dialogs.stayOnTop(loadTexturesConfirm); if (loadTexturesConfirm.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.YES) { @@ -171,7 +178,6 @@ private void loadWorld(World world, WorldMapLoader mapLoader) { } } }); - mapLoader.loadWorld(world.getWorldDirectory()); } /** @@ -188,20 +194,15 @@ private void fillWorldList(final File worldSavesDir) { statusLabel.setText("Loading worlds list..."); disableControls(true); - Task> loadWorldsTask = new Task>() { + Task> loadWorldsTask = new Task<>() { @Override - protected List call() { - List worlds = new ArrayList<>(); + protected List call() { + List worlds = new ArrayList<>(); if (worldSavesDir != null) { File[] worldDirs = worldSavesDir.listFiles(); if (worldDirs != null) { for (File dir : worldDirs) { - if (World.isWorldDir(dir)) { - World world = World.loadWorld(dir, Dimension.Identifier.OVERWORLD, World.LoggedWarnings.SILENT); - if (world != EmptyWorld.INSTANCE) { - worlds.add(world); - } - } + worlds.addAll(WorldFormats.getInfos(dir.toPath())); } } } @@ -210,7 +211,7 @@ protected List call() { }; loadWorldsTask.setOnSucceeded((WorkerStateEvent event) -> { - List worlds = loadWorldsTask.getValue(); + List worlds = loadWorldsTask.getValue(); worldTbl.setItems(FXCollections.observableArrayList(worlds)); if (!worlds.isEmpty()) { diff --git a/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java index 095e056f55..056c2381b5 100644 --- a/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java +++ b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java @@ -17,6 +17,7 @@ */ package se.llbit.chunky.ui.render.tabs; +import it.unimi.dsi.fastutil.ints.IntIntPair; import javafx.beans.binding.Bindings; import javafx.beans.value.ChangeListener; import javafx.fxml.FXML; @@ -47,6 +48,7 @@ import se.llbit.chunky.ui.elements.SizeInput; import se.llbit.chunky.ui.render.RenderControlsTab; import se.llbit.chunky.world.EmptyWorld; +import se.llbit.chunky.world.HeightRange; import se.llbit.chunky.world.Icon; import se.llbit.chunky.world.World; import se.llbit.fxutil.Dialogs; @@ -592,12 +594,14 @@ private void updateCanvasCrop() { } private void updateYClipSlidersRanges(World world) { - if (world != null && world.getVersionId() >= World.VERSION_21W06A) { - yMin.setRange(-64, 320); - yMax.setRange(-64, 320); - } else { - yMin.setRange(0, 256); - yMax.setRange(0, 256); + if (world != null) { + HeightRange heightRange = world.currentDimension().heightRange(); + int min = heightRange.min(); + int max = heightRange.max(); + yMin.setRange(min, max); + yMin.set(min); + yMax.setRange(min, max); + yMax.set(max); } } } diff --git a/chunky/src/java/se/llbit/chunky/world/Chunk.java b/chunky/src/java/se/llbit/chunky/world/Chunk.java index 55599bbab8..7bed3349e7 100644 --- a/chunky/src/java/se/llbit/chunky/world/Chunk.java +++ b/chunky/src/java/se/llbit/chunky/world/Chunk.java @@ -17,35 +17,17 @@ package se.llbit.chunky.world; import se.llbit.chunky.block.minecraft.Air; -import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; import se.llbit.chunky.block.Block; import se.llbit.chunky.block.minecraft.Lava; import se.llbit.chunky.block.minecraft.Water; -import se.llbit.chunky.block.legacy.LegacyBlocks; import se.llbit.chunky.chunk.BlockPalette; import se.llbit.chunky.chunk.ChunkData; import se.llbit.chunky.chunk.ChunkLoadingException; -import se.llbit.chunky.chunk.EmptyChunkData; -import se.llbit.chunky.chunk.biome.BiomeDataFactory; import se.llbit.chunky.map.*; -import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; -import se.llbit.chunky.world.region.MCRegion; -import se.llbit.chunky.world.region.Region; -import se.llbit.log.Log; -import se.llbit.math.QuickMath; -import se.llbit.nbt.*; -import se.llbit.util.BitBuffer; import se.llbit.util.Mutable; import se.llbit.util.annotation.NotNull; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import static se.llbit.util.NbtUtil.getTagFromNames; -import static se.llbit.util.NbtUtil.tagFromMap; - /** * This class represents a loaded or not-yet-loaded chunk in the world. *

@@ -53,7 +35,7 @@ * * @author Jesper Öqvist (jesper@llbit.se) */ -public class Chunk { +public abstract class Chunk { public static final String DATAVERSION = ".DataVersion"; public static final String LEVEL_HEIGHTMAP = ".Level.HeightMap"; @@ -87,7 +69,7 @@ public class Chunk { protected volatile AbstractLayer surface = IconLayer.UNKNOWN; protected volatile AbstractLayer biomes = IconLayer.UNKNOWN; - private final Dimension dimension; + protected final Dimension dimension; protected int dataTimestamp = 0; protected int surfaceTimestamp = 0; @@ -112,27 +94,6 @@ public int biomeColor() { return biomes.getAvgColor(); } - /** - * @param request fresh request set - * @return loaded data, or null if something went wrong - */ - private Map getChunkTags(Set request) throws ChunkLoadingException { - MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); - Mutable timestamp = new Mutable<>(dataTimestamp); - Map chunkTags = region.getChunkTags(this.position, request, timestamp); - this.dataTimestamp = timestamp.get(); - return chunkTags; - } - - /** - * @param request fresh request set - * @return loaded data, or null if something went wrong - */ - private Map getEntityTags(Set request) throws ChunkLoadingException { - MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); - return region.getEntityTags(this.position, request); - } - /** * Reset the rendered layers in this chunk. */ @@ -148,208 +109,11 @@ public ChunkPosition getPosition() { } /** - * Parse the chunk from the region file and render the current - * layer, surface and cave maps. + * Parse the chunk from the region file and render the current layer, surface and cave maps. + * * @return whether the input chunkdata was modified */ - public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { - if (!shouldReloadChunk()) { - return false; - } - - Set request = new HashSet<>(); - request.add(Chunk.DATAVERSION); - request.add(Chunk.LEVEL_SECTIONS); - request.add(Chunk.SECTIONS_POST_21W39A); - request.add(Chunk.LEVEL_BIOMES); - request.add(Chunk.BIOMES_POST_21W39A); - request.add(Chunk.LEVEL_HEIGHTMAP); - - Map dataMap; - try { - dataMap = getChunkTags(request); - } catch (ChunkLoadingException e) { // we don't want to crash the map view if a chunk fails to load, so we warn the user - Log.warn(String.format("Failed to load chunk %s", position), e); - return false; - } - // TODO: improve error handling here. - if (dataMap == null) { - return false; - } - Tag data = tagFromMap(dataMap); - - surfaceTimestamp = dataTimestamp; - version = chunkVersion(data); - IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); - chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); - loadSurface(data, chunkData.get(), yMin, yMax); - biomesTimestamp = dataTimestamp; - - dimension.chunkUpdated(position); - return true; - } - - private void loadSurface(@NotNull Tag data, ChunkData chunkData, int yMin, int yMax) { - if (data == null) { - surface = IconLayer.CORRUPT; - return; - } - - Heightmap heightmap = dimension.getHeightmap(); - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - if (sections.isList()) { - if (version == ChunkVersion.PRE_FLATTENING || version == ChunkVersion.POST_FLATTENING) { - BiomePalette biomePalette = new ArrayBiomePalette(); - BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, yMin, yMax); - biomes = new BiomeLayer(chunkData, biomePalette); - - BlockPalette palette = new BlockPalette(); - palette.unsynchronize(); //only this RegionParser will use this palette - loadBlockData(data, chunkData, palette, yMin, yMax); - - int[] heightmapData = extractHeightmapData(data, chunkData); - updateHeightmap(heightmap, position, chunkData, heightmapData, palette, yMax); - - surface = new SurfaceLayer(dimension.getDimensionId(), chunkData, palette, biomePalette, yMin, yMax, heightmapData); - queueTopography(); - } - } else { - surface = IconLayer.CORRUPT; - } - } - - private int[] extractHeightmapData(@NotNull Tag data, ChunkData chunkData) { - Tag heightmapTag = data.get(LEVEL_HEIGHTMAP); - if (heightmapTag.isIntArray(X_MAX * Z_MAX)) { - return heightmapTag.intArray(); - } else { - int[] fallback = new int[X_MAX * Z_MAX]; - for (int i = 0; i < fallback.length; ++i) { - fallback[i] = chunkData.maxY(); - } - return fallback; - } - } - - /** Detect Minecraft version that generated the chunk. */ - private static ChunkVersion chunkVersion(@NotNull Tag data) { - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - if (sections.isList()) { - for (SpecificTag section : sections.asList()) { - if (!section.get("Palette").isList()) { - if (section.get("Blocks").isByteArray(SECTION_BYTES)) { - return ChunkVersion.PRE_FLATTENING; - } - } - } - return ChunkVersion.POST_FLATTENING; - } - return ChunkVersion.UNKNOWN; - } - - private static void loadBlockData(@NotNull Tag data, @NotNull ChunkData chunkData, - BlockPalette blockPalette, int minY, int maxY) { - - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - if (sections.isList()) { - for (SpecificTag section : sections.asList()) { - Tag yTag = section.get("Y"); - int sectionY = yTag.byteValue(); - int sectionMinBlockY = sectionY << 4; - - if(sectionY < minY >> 4 || sectionY-1 > (maxY >> 4)+1) - continue; //skip parsing sections that are outside requested bounds - - Tag blockPaletteTag = getTagFromNames(section, "Palette", "block_states\\palette"); - if (blockPaletteTag.isList()) { - ListTag localBlockPalette = blockPaletteTag.asList(); - // Bits per block: - int bpb = 4; - if (localBlockPalette.size() > 16) { - bpb = QuickMath.log2(QuickMath.nextPow2(localBlockPalette.size())); - } - - int dataSize = (4096 * bpb) / 64; - Tag blockStates = getTagFromNames(section, "BlockStates", "block_states\\data"); - - if (blockStates.isLongArray(dataSize)) { - // since 20w17a, block states are aligned to 64-bit boundaries, so there are 64 % bpb - // unused bits per block state; if so, the array is longer than the expected data size - boolean isAligned = data.get(DATAVERSION).intValue() >= DATAVERSION_20W17A; - if (isAligned) { - // entries are 64-bit-padded, re-calculate the bits per block - // this is the dataSize calculation from above reverted, we know the actual data size - bpb = blockStates.longArray().length / 64; - } - - int[] subpalette = new int[localBlockPalette.size()]; - int paletteIndex = 0; - for (Tag item : localBlockPalette.asList()) { - subpalette[paletteIndex] = blockPalette.put(item); - paletteIndex += 1; - } - BitBuffer buffer = new BitBuffer(blockStates.longArray(), bpb, isAligned); - for (int y = 0; y < SECTION_Y_MAX; y++) { - int blockY = sectionMinBlockY + y; - for (int z = 0; z < Z_MAX; z++) { - for(int x = 0; x < X_MAX; x++) { - int b0 = buffer.read(); - if (b0 < subpalette.length) { - chunkData.setBlockAt(x, blockY, z, subpalette[b0]); - } - } - } - } - } else { - // Single block palette - if (localBlockPalette.size() == 1) { - // Check it is not air block - int block = blockPalette.put(localBlockPalette.get(0)); - if (block != blockPalette.airId) { - // Set the entire section - for (int y = 0; y < SECTION_Y_MAX; y++) { - int blockY = sectionMinBlockY + y; - for (int z = 0; z < Z_MAX; z++) { - for(int x = 0; x < X_MAX; x++) { - chunkData.setBlockAt(x, blockY, z, block); - } - } - } - } - } - } - } else { - int yOffset = sectionY & 0xFF; - - Tag dataTag = section.get("Data"); - byte[] blockDataBytes = new byte[(Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX) / 2]; - if (dataTag.isByteArray(SECTION_HALF_NIBBLES)) { - System.arraycopy(dataTag.byteArray(), 0, blockDataBytes, SECTION_HALF_NIBBLES * yOffset, - SECTION_HALF_NIBBLES); - } - - Tag blocksTag = section.get("Blocks"); - if (blocksTag.isByteArray(SECTION_BYTES)) { - byte[] blocksBytes = new byte[Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX]; - System.arraycopy(blocksTag.byteArray(), 0, blocksBytes, SECTION_BYTES * yOffset, - SECTION_BYTES); - - int offset = SECTION_BYTES * yOffset; - for (int y = 0; y < SECTION_Y_MAX; y++) { - int blockY = sectionMinBlockY + y; - for (int z = 0; z < Z_MAX; z++) { - for (int x = 0; x < X_MAX; x++) { - chunkData.setBlockAt(x, blockY, z, blockPalette.put( - LegacyBlocks.getTag(offset, blocksBytes, blockDataBytes))); - offset += 1; - } - } - } - } - } - } - } - } + public abstract boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax); /** * Load heightmap information from a chunk heightmap array @@ -370,17 +134,6 @@ protected static void updateHeightmap(Heightmap heightmap, ChunkPosition pos, Ch } } - protected boolean shouldReloadChunk() { - int timestamp = Integer.MAX_VALUE; - timestamp = Math.min(timestamp, surfaceTimestamp); - timestamp = Math.min(timestamp, biomesTimestamp); - if (timestamp == 0) { - return true; - } - Region region = dimension.getRegion(position.getRegionPosition()); - return region.chunkChangedSince(position, timestamp); - } - protected void queueTopography() { for (int x = -1; x <= 1; ++x) { for (int z = -1; z <= 1; ++z) { @@ -445,97 +198,7 @@ public synchronized void renderTopography() { * @param maxY The requested maximum Y to be loaded into the chunkData object. The chunk implementation does NOT have to respect it * @throws ChunkLoadingException If there is an issue loading the chunk, and it should be aborted */ - public synchronized void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { - Set request = new HashSet<>(); - request.add(DATAVERSION); - request.add(LEVEL_SECTIONS); - request.add(SECTIONS_POST_21W39A); - request.add(LEVEL_BIOMES); - request.add(BIOMES_POST_21W39A); - request.add(LEVEL_ENTITIES); - request.add(LEVEL_TILEENTITIES); - request.add(BLOCK_ENTITIES_POST_21W43A); - Map dataMap = getChunkTags(request); - // TODO: improve error handling here. - if (dataMap == null) { - throw new ChunkLoadingException(String.format("Got null data for chunk %s", this.position)); - } - Tag data = tagFromMap(dataMap); - - int dataVersion = data.get(DATAVERSION).intValue(); - - IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); - - if(reuseChunkData.get() == null || reuseChunkData.get() instanceof EmptyChunkData) { - reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); - } else { - reuseChunkData.get().clear(); - } - ChunkData chunkData = reuseChunkData.get(); //unwrap mutable, for ease of use - - version = chunkVersion(data); - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - Tag entitiesTag = data.get(LEVEL_ENTITIES); - Tag tileEntitiesTag = getTagFromNames(data, LEVEL_TILEENTITIES, BLOCK_ENTITIES_POST_21W43A); - - BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, minY, maxY); - if (sections.isList()) { - loadBlockData(data, chunkData, palette, minY, maxY); - - if (entitiesTag.isList()) { - for (SpecificTag tag : (ListTag) entitiesTag) { - if (tag.isCompoundTag()) - chunkData.addEntity((CompoundTag) tag); - } - } - - if (tileEntitiesTag.isList()) { - for (SpecificTag tag : (ListTag) tileEntitiesTag) { - if (tag.isCompoundTag()) - chunkData.addTileEntity((CompoundTag) tag); - } - } - } - - // post 20w45A entities - if (dataVersion >= DATAVERSION_20W45A) { - Set entitiesRequest = new HashSet<>(); - entitiesRequest.add(ENTITIES_POST_20W45A); - - Map entitiesMap = getEntityTags(entitiesRequest); - if (entitiesMap != null) { - entitiesTag = entitiesMap.get(".Entities"); - if (entitiesTag.isList()) { - for (SpecificTag tag : (ListTag) entitiesTag) { - if (tag.isCompoundTag()) - chunkData.addEntity((CompoundTag) tag); - } - } - } - } - } - - /** - * @return The min and max blockY for a given section array - */ - private IntIntImmutablePair inclusiveChunkBounds(Tag chunkData) { - Tag sections = getTagFromNames(chunkData, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - int minSectionY = Integer.MAX_VALUE; - int maxSectionY = Integer.MIN_VALUE; - if (sections.isList()) { - for (SpecificTag section : sections.asList()) { - byte sectionY = (byte) section.get("Y").byteValue(); - if (sectionY < minSectionY) { - minSectionY = sectionY; - } - if (sectionY > maxSectionY) { - maxSectionY = sectionY; - } - } - } - - return new IntIntImmutablePair(minSectionY << 4, (maxSectionY << 4) + 15); - } + public abstract void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException; /** * @return Integer index into a chunk YXZ array diff --git a/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java b/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java index 2c1751c914..989931d74b 100644 --- a/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java +++ b/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java @@ -20,7 +20,7 @@ import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; import se.llbit.chunky.world.listeners.ChunkDeletionListener; import se.llbit.chunky.world.listeners.ChunkUpdateListener; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -42,7 +42,7 @@ public class ChunkSelectionTracker implements ChunkDeletionListener { */ private boolean setChunk(ChunkPosition pos, boolean selected) { long regionPosLong = ChunkPosition.positionToLong(pos.x >> 5, pos.z >> 5); - BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPosLong, p -> new BitSet(MCRegion.CHUNKS_X * MCRegion.CHUNKS_Z)); + BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPosLong, p -> new BitSet(Region.CHUNKS_X * Region.CHUNKS_Z)); int bitIndex = (pos.x & 31) + ((pos.z & 31) << 5); boolean previousValue = selectedChunksForRegion.get(bitIndex); if(previousValue != selected) { @@ -75,7 +75,7 @@ private boolean setChunk(Dimension dimension, ChunkPosition pos, boolean selecte * @return Whether the selection changed */ private boolean setChunksWithinRegion(Dimension dimension, RegionPosition regionPos, int minX, int maxX, int minZ, int maxZ, boolean selected) { - BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPos.getLong(), p -> new BitSet(MCRegion.CHUNKS_X * MCRegion.CHUNKS_Z)); + BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPos.getLong(), p -> new BitSet(Region.CHUNKS_X * Region.CHUNKS_Z)); Collection changedChunks = new ArrayList<>(); boolean selectionChanged = false; @@ -249,15 +249,15 @@ public synchronized boolean setChunks(Dimension dimension, int minChunkX, int mi boolean selectionChanged = false; // If selection area must contain complete regions - if(maxChunkX - minChunkX >= MCRegion.CHUNKS_X*2 && maxChunkZ - minChunkZ >= MCRegion.CHUNKS_Z*2) { + if(maxChunkX - minChunkX >= Region.CHUNKS_X*2 && maxChunkZ - minChunkZ >= Region.CHUNKS_Z*2) { // All full regions are set first, then any chunks on the borders are set, top and bottom include corners, left and right don't // left, right, top, bottom are unrelated to the actual map view, and are just treating XZ as if they were XY on traditional cartesian coordinate axes - int leftBorder = MCRegion.CHUNKS_X - (minChunkX & (MCRegion.CHUNKS_X-1)); // want the border from minimum region corner to minimum chunk corner, so 32 - borderSize - int rightBorder = maxChunkX & (MCRegion.CHUNKS_X-1); + int leftBorder = Region.CHUNKS_X - (minChunkX & (Region.CHUNKS_X-1)); // want the border from minimum region corner to minimum chunk corner, so 32 - borderSize + int rightBorder = maxChunkX & (Region.CHUNKS_X-1); - int bottomBorder = MCRegion.CHUNKS_Z - (minChunkZ & (MCRegion.CHUNKS_Z-1)); - int topBorder = maxChunkZ & (MCRegion.CHUNKS_Z-1); + int bottomBorder = Region.CHUNKS_Z - (minChunkZ & (Region.CHUNKS_Z-1)); + int topBorder = maxChunkZ & (Region.CHUNKS_Z-1); int minInnerRegionX = (minChunkX + leftBorder) >> 5; int maxInnerRegionX = (maxChunkX - rightBorder) >> 5; @@ -397,9 +397,9 @@ public synchronized Map> getSelectionByRegio selectedChunksByRegion.forEach((regionPosition, selectedChunksBitSet) -> { RegionPosition regionPos = new RegionPosition(regionPosition); List positions = new ArrayList<>(); - for (int localX = 0; localX < MCRegion.CHUNKS_X; localX++) { - for (int localZ = 0; localZ < MCRegion.CHUNKS_Z; localZ++) { - int idx = localX + (localZ * MCRegion.CHUNKS_X); + for (int localX = 0; localX < Region.CHUNKS_X; localX++) { + for (int localZ = 0; localZ < Region.CHUNKS_Z; localZ++) { + int idx = localX + (localZ * Region.CHUNKS_X); if(selectedChunksBitSet.get(idx)) { positions.add(regionPos.asChunkPosition(localX, localZ)); } diff --git a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java index 778f3a69d1..f2e17d6443 100644 --- a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java @@ -3,9 +3,13 @@ import se.llbit.chunky.chunk.ChunkData; import se.llbit.chunky.chunk.GenericChunkData; import se.llbit.chunky.chunk.biome.BiomeData2d; +import se.llbit.chunky.world.java.JavaDimension; +import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.region.EmptyRegion; import se.llbit.chunky.world.region.ImposterCubicRegion; import se.llbit.chunky.world.region.Region; +import se.llbit.math.Vector3i; +import se.llbit.util.annotation.Nullable; import java.io.File; import java.io.IOException; @@ -17,13 +21,13 @@ import static se.llbit.chunky.world.region.ImposterCubicRegion.blockToCube; import static se.llbit.chunky.world.region.ImposterCubicRegion.cubeToCubicRegion; -public class CubicDimension extends Dimension { +public class CubicDimension extends JavaDimension { /** * @param dimensionDirectory Minecraft world directory. */ - protected CubicDimension(World world, Dimension.Identifier dimensionId, File dimensionDirectory, Set playerEntities) { - super(world, dimensionId, dimensionDirectory, playerEntities); + public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + super(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } /** @@ -31,7 +35,7 @@ protected CubicDimension(World world, Dimension.Identifier dimensionId, File dim */ @Override public synchronized File getRegionDirectory() { - return new File(dimensionDirectory, "region3d"); + return dimensionDirectory.resolve("region3d").toFile(); } @Override @@ -49,11 +53,11 @@ public Region createRegion(RegionPosition pos) { return new ImposterCubicRegion(pos, this); } - public synchronized Region getRegionWithinRange(RegionPosition pos, int minY, int maxY) { + public synchronized Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { return regionMap.computeIfAbsent(pos.getLong(), p -> { // check if the region is present in the world directory Region region = EmptyRegion.instance; - if (regionExistsWithinRange(pos, minY, maxY)) { + if (this.hasRegionWithinRange(pos, heightRange)) { region = createRegion(pos); } return region; @@ -62,10 +66,9 @@ public synchronized Region getRegionWithinRange(RegionPosition pos, int minY, in /** no choice but to iterate over every file in the directory */ @Override - public boolean regionExists(RegionPosition pos) { + public boolean hasRegion(RegionPosition pos) { File regionDirectory = getRegionDirectory(); - try { - Stream list = Files.list(regionDirectory.toPath()); + try (Stream list = Files.list(regionDirectory.toPath())) { return list.anyMatch(path -> { String[] split = path.getFileName().toString().split("[.]"); if(split.length == 4) { @@ -83,13 +86,13 @@ public boolean regionExists(RegionPosition pos) { } @Override - public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { + public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) { int cubicRegionX = pos.x << 1; int cubicRegionZ = pos.z << 1; File regionDirectory = getRegionDirectory(); - int minRegionY = cubeToCubicRegion(blockToCube(minY)); - int maxRegionY = cubeToCubicRegion(blockToCube(maxY - 1)); + int minRegionY = cubeToCubicRegion(blockToCube(heightRange().min())); + int maxRegionY = cubeToCubicRegion(blockToCube(heightRange.max() - 1)); for (int y = minRegionY; y <= maxRegionY; y++) { for (int localX = 0; localX < ImposterCubicRegion.DIAMETER_IN_CUBIC_REGIONS; localX++) { for (int localZ = 0; localZ < ImposterCubicRegion.DIAMETER_IN_CUBIC_REGIONS; localZ++) { diff --git a/chunky/src/java/se/llbit/chunky/world/Dimension.java b/chunky/src/java/se/llbit/chunky/world/Dimension.java index 34c0b2b8ee..7a42e35d8b 100644 --- a/chunky/src/java/se/llbit/chunky/world/Dimension.java +++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java @@ -1,7 +1,5 @@ package se.llbit.chunky.world; -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.chunk.ChunkData; import se.llbit.chunky.chunk.GenericChunkData; @@ -17,13 +15,13 @@ import se.llbit.math.Vector3i; import se.llbit.util.annotation.Nullable; -import java.io.File; +import java.nio.file.Path; import java.util.*; /** * */ -public class Dimension { +public abstract class Dimension { public record Identifier(String namespace, String name) { public static Identifier OVERWORLD = new Identifier("minecraft", "overworld"); public static Identifier THE_NETHER = new Identifier("minecraft", "the_nether"); @@ -63,77 +61,52 @@ public String toString() { } } - private final World world; + protected final Path dimensionDirectory; - protected final Long2ObjectMap regionMap = new Long2ObjectOpenHashMap<>(); + protected final Heightmap heightmap = new Heightmap(); - protected final File dimensionDirectory; - private Set playerEntities; + protected final Identifier dimensionId; - private final Heightmap heightmap = new Heightmap(); + @Nullable protected final Vector3i spawnPos; + protected final Set playerEntities; - private final Identifier dimensionId; - - private final Collection chunkDeletionListeners = new LinkedList<>(); - private final Collection chunkTopographyListeners = new LinkedList<>(); - private final Collection chunkUpdateListeners = new LinkedList<>(); - - private Vector3i spawnPos = null; + protected final Collection chunkDeletionListeners = new LinkedList<>(); + protected final Collection chunkTopographyListeners = new LinkedList<>(); + protected final Collection chunkUpdateListeners = new LinkedList<>(); /** * @param dimensionDirectory Minecraft world directory. */ - protected Dimension(World world, Identifier dimensionId, File dimensionDirectory, Set playerEntities) { - this.world = world; + protected Dimension(Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { this.dimensionId = dimensionId; - this.dimensionDirectory = dimensionDirectory; this.playerEntities = playerEntities; - } - - public Identifier getDimensionId() { - return dimensionId; + this.dimensionDirectory = dimensionDirectory; + this.spawnPos = spawnPos; } /** - * Reload player data. - * - * @return {@code true} if player data was reloaded. + * @return A user presentable name of the dimension */ - public synchronized boolean reloadPlayerData() { - return this.world.reloadPlayerData(); - } + public abstract String getName(); - /** - * Add a chunk deletion listener. - */ - public void addChunkDeletionListener(ChunkDeletionListener listener) { - synchronized (chunkDeletionListeners) { - chunkDeletionListeners.add(listener); - } + public Identifier getDimensionId() { + return dimensionId; } /** - * Add a region discovery listener. + * Get the data directory for the given dimension. + *

This may be the same as the world directory for some dimensions

+ * + * @return The path to the dimension */ - public void addChunkUpdateListener(ChunkUpdateListener listener) { - synchronized (chunkUpdateListeners) { - chunkUpdateListeners.add(listener); - } - } - - private void fireChunkDeleted(ChunkPosition chunk) { - synchronized (chunkDeletionListeners) { - for (ChunkDeletionListener listener : chunkDeletionListeners) - listener.chunkDeleted(chunk); - } + protected synchronized Path getDimensionPath() { + return dimensionDirectory; } /** * @return The chunk at the given position */ - public synchronized Chunk getChunk(ChunkPosition pos) { - return getRegion(pos.getRegionPosition()).getChunk(pos); - } + public abstract Chunk getChunk(ChunkPosition pos); /** * Returns a ChunkData instance that is compatible with the given chunk version. @@ -153,147 +126,134 @@ public ChunkData createChunkData(@Nullable ChunkData chunkData, int minY, int ma return new GenericChunkData(); } - public Region createRegion(RegionPosition pos) { - return new MCRegion(pos, this); - } + public abstract Region createRegion(RegionPosition pos); - public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { - return new MCRegionChangeWatcher(worldMapLoader, mapView); - } + public abstract RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView); /** * @param pos Region position * @return The region at the given position */ - public synchronized Region getRegion(RegionPosition pos) { - return regionMap.computeIfAbsent(pos.getLong(), p -> { - // check if the region is present in the world directory - Region region = EmptyRegion.instance; - if (regionExists(pos)) { - region = createRegion(pos); - } - return region; - }); - } - - public Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) { - return getRegion(pos); - } + public abstract Region getRegion(RegionPosition pos); - /** - * Set the region for the given position. - */ - public synchronized void setRegion(RegionPosition pos, Region region) { - regionMap.put(pos.getLong(), region); - } + public abstract Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange); /** * @param pos region position * @return {@code true} if a region file exists for the given position */ - public boolean regionExists(RegionPosition pos) { - File regionFile = new File(getRegionDirectory(), pos.getMcaName()); - return regionFile.exists(); - } + public abstract boolean hasRegion(RegionPosition pos); /** * @param pos Position of the region to load - * @param minY Minimum block Y (inclusive) - * @param maxY Maximum block Y (exclusive) + * @param heightRange The height range of the request * @return Whether the region exists */ - public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { - return this.regionExists(pos); - } + public abstract boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange); /** - * Get the data directory for the given dimension. + * WARNING: In some dimensions this could be from {@link Integer#MIN_VALUE} to {@link Integer#MAX_VALUE} + *

+ * Lower bound is inclusive, upper is exclusive * - * @return File object pointing to the data directory + * @return The height range of the dimension. */ - protected synchronized File getDimensionDirectory() { - return dimensionDirectory; - } + public abstract HeightRange heightRange(); /** - * @return File object pointing to the region file directory + * @return The chunk heightmap */ - public synchronized File getRegionDirectory() { - return new File(getDimensionDirectory(), "region"); + public Heightmap getHeightmap() { + return heightmap; } + public Optional getSpawnPosition() { + return Optional.ofNullable(this.spawnPos); + } + + /** + * Reload player data. + * + * @return {@code true} if player data was reloaded. + */ + public abstract boolean reloadPlayerData(); + /** * Get the current player position as an optional vector. * *

The result is empty if this is not a single player world. */ - public synchronized Optional getPlayerPos() { - if (!playerEntities.isEmpty()) { - return world.getSingleplayerPlayerUuid() - .flatMap(uuid -> playerEntities.stream() - .filter(player -> player.uuid.equals(uuid)) - .map(pos -> new Vector3(pos.x, pos.y, pos.z)) - .findFirst()); - } else { - return Optional.empty(); - } - } + public abstract Optional getPlayerPos(); /** - * @return The chunk heightmap + * Load entities from world the file. + * This is usually the single player entity in a local save. */ - public Heightmap getHeightmap() { - return heightmap; + public synchronized Collection getPlayerEntities() { + Collection list = new LinkedList<>(); + if (PersistentSettings.getLoadPlayers()) { + for (PlayerEntityData data : playerEntities) { + list.add(new PlayerEntity(data)); + } + } + return list; + } + + public synchronized Collection getPlayerPositions() { + return Collections.unmodifiableSet(playerEntities); } /** - * Called when a new region has been discovered by the region parser. + * Add a chunk deletion listener. */ - public void regionDiscovered(RegionPosition pos) { - synchronized (this) { - regionMap.computeIfAbsent(pos.getLong(), p -> createRegion(pos)); + public void addChunkDeletionListener(ChunkDeletionListener listener) { + synchronized (chunkDeletionListeners) { + chunkDeletionListeners.add(listener); } } /** - * Notify region update listeners. + * Called when chunks have been deleted from this world. + * Triggers the chunk deletion listeners. + * + * @param pos Position of deleted chunk */ - private void fireChunkUpdated(ChunkPosition chunk) { - synchronized (chunkUpdateListeners) { - for (ChunkUpdateListener listener : chunkUpdateListeners) { - listener.chunkUpdated(chunk); - } + public void chunkDeleted(ChunkPosition pos) { + synchronized (chunkDeletionListeners) { + for (ChunkDeletionListener listener : chunkDeletionListeners) + listener.chunkDeleted(pos); } } /** - * Notify region update listeners. + * Add a region discovery listener. */ - private void fireRegionUpdated(RegionPosition region) { + public void addChunkUpdateListener(ChunkUpdateListener listener) { synchronized (chunkUpdateListeners) { - for (ChunkUpdateListener listener : chunkUpdateListeners) { - listener.regionUpdated(region); - } + chunkUpdateListeners.add(listener); } } - @Override - public String toString() { - return dimensionDirectory.getName(); - } - /** * Called when a chunk has been updated. */ public void chunkUpdated(ChunkPosition chunk) { - fireChunkUpdated(chunk); + synchronized (chunkUpdateListeners) { + for (ChunkUpdateListener listener : chunkUpdateListeners) { + listener.chunkUpdated(chunk); + } + } } /** * Called when a chunk has been updated. */ public void regionUpdated(RegionPosition region) { - fireRegionUpdated(region); + synchronized (chunkUpdateListeners) { + for (ChunkUpdateListener listener : chunkUpdateListeners) { + listener.regionUpdated(region); + } + } } /** @@ -324,48 +284,4 @@ public void chunkTopographyUpdated(Chunk chunk) { listener.chunksTopographyUpdated(chunk); } } - - public Optional getSpawnPosition() { - return Optional.ofNullable(this.spawnPos); - } - - public void setSpawnPos(@Nullable Vector3i spawnPos) { - this.spawnPos = spawnPos; - } - - /** - * Called when chunks have been deleted from this world. - * Triggers the chunk deletion listeners. - * - * @param pos Position of deleted chunk - */ - public void chunkDeleted(ChunkPosition pos) { - fireChunkDeleted(pos); - } - - public Date getLastModified() { - return new Date(this.dimensionDirectory.lastModified()); - } - - /** - * Load entities from world the file. - * This is usually the single player entity in a local save. - */ - public synchronized Collection getPlayerEntities() { - Collection list = new LinkedList<>(); - if (PersistentSettings.getLoadPlayers()) { - for (PlayerEntityData data : playerEntities) { - list.add(new PlayerEntity(data)); - } - } - return list; - } - - public synchronized Collection getPlayerPositions() { - return Collections.unmodifiableSet(playerEntities); - } - - public synchronized void setPlayerEntities(Set playerEntities) { - this.playerEntities = playerEntities; - } } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java b/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java index dd1ed4a263..7065e224cb 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java @@ -24,6 +24,7 @@ import se.llbit.chunky.ui.ChunkMap; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.util.Mutable; +import se.llbit.util.annotation.NotNull; /** * Empty or non-existent chunk in a region that does exist. @@ -86,7 +87,7 @@ private void renderEmpty(MapTile tile) { // Do nothing. } - @Override public synchronized boolean loadChunk(Mutable chunkData, int yMin, int yMax) { + @Override public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { return false; } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java index cf112ff995..e627aebe74 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java @@ -1,14 +1,76 @@ package se.llbit.chunky.world; +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; +import it.unimi.dsi.fastutil.ints.IntIntPair; +import se.llbit.chunky.map.MapView; +import se.llbit.chunky.map.WorldMapLoader; +import se.llbit.chunky.world.region.EmptyRegion; +import se.llbit.chunky.world.region.Region; +import se.llbit.chunky.world.region.RegionChangeWatcher; +import se.llbit.math.Vector3; + import java.util.Collections; +import java.util.Optional; public class EmptyDimension extends Dimension { EmptyDimension() { - super(EmptyWorld.INSTANCE, Dimension.Identifier.OVERWORLD, null, Collections.emptySet()); + super(Dimension.Identifier.OVERWORLD, null, Collections.emptySet(), null); + } + + @Override + public Chunk getChunk(ChunkPosition pos) { + return EmptyChunk.INSTANCE; + } + + @Override + public Region createRegion(RegionPosition pos) { + return EmptyRegion.instance; + } + + @Override + public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { + return new RegionChangeWatcher(worldMapLoader, mapView, "Empty Region Change Watcher") { + @Override + public void run() {} + }; + } + + @Override + public Region getRegion(RegionPosition pos) { + return EmptyRegion.instance; + } + + @Override + public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { + return EmptyRegion.instance; + } + + @Override + public boolean hasRegion(RegionPosition pos) { + return false; } @Override - public String toString() { + public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) { + return false; + } + + @Override + public HeightRange heightRange() { + return new HeightRange(0, 0); + } + + @Override public String getName() { return "[empty dimension]"; } + + @Override + public boolean reloadPlayerData() { + return false; + } + + @Override + public Optional getPlayerPos() { + return Optional.empty(); + } } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java index 029f664585..c4b2f533ca 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java @@ -23,10 +23,11 @@ import se.llbit.chunky.ui.ChunkMap; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.util.Mutable; +import se.llbit.util.annotation.NotNull; /** * Empty or non-existent chunk in a region that does not exist. - * In the {@link ChunkMap map view} an {@link EmptyChunk} is represented as gray. + * In the {@link ChunkMap map view} an {@link EmptyRegionChunk} is represented as gray. * * @author Jesper Öqvist */ @@ -48,7 +49,7 @@ private EmptyRegionChunk() { surface = IconLayer.CORRUPT; } - @Override public synchronized void getChunkData(Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int yMin, int yMax) { } + @Override public synchronized void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int yMin, int yMax) { } @Override public void renderSurface(MapTile tile) { renderEmpty(tile); @@ -80,7 +81,7 @@ private void renderEmpty(MapTile tile) { // do nothing } - @Override public synchronized boolean loadChunk(Mutable chunkData, int yMin, int yMax) { + @Override public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { return false; } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java index d302150b49..100993cc70 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java @@ -16,6 +16,14 @@ */ package se.llbit.chunky.world; +import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.util.annotation.NotNull; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + /** * Represents an empty or non-existent world. * @@ -27,18 +35,62 @@ public class EmptyWorld extends World { public static final EmptyWorld INSTANCE = new EmptyWorld(); private EmptyWorld() { - super("[empty world]", null, 0, -1); - this.currentDimension = new EmptyDimension(); + super(new World.Info("[empty world]", Path.of(""), 0, 0, "Survival", + new WorldFormat() { + @Override + public boolean isValid(@NotNull Path path) { + return false; + } + + @NotNull + @Override + public Optional getWorldInfo(@NotNull Path path) { + return Optional.empty(); + } + + @NotNull + @Override + public World loadWorld(@NotNull Info info) { + return EmptyWorld.INSTANCE; + } + + @Override + public String getName() { + return "Empty World Format"; + } + + @Override + public String getDescription() { + return ""; + } + + @Override + public String getId() { + return ""; + } + }), + new EmptyDimension() + ); } @Override - public void loadDimension(Dimension.Identifier dimensionId) { + public Set getAvailableDimensions() { + return Collections.emptySet(); + } + + @Override + public Optional getDefaultDimension() { + return Optional.empty(); + } + + @Override + public Dimension loadDimension(Dimension.Identifier dimensionId) { // no-op + return this.currentDimension; } @Override public String toString() { return "[empty world]"; } - } diff --git a/chunky/src/java/se/llbit/chunky/world/HeightRange.java b/chunky/src/java/se/llbit/chunky/world/HeightRange.java new file mode 100644 index 0000000000..b1105efedf --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/HeightRange.java @@ -0,0 +1,7 @@ +package se.llbit.chunky.world; + +/** + * @param min Lower (INCLUSIVE) bound of the range + * @param max Upper (EXCLUSIVE) bound of the range + */ +public record HeightRange(int min, int max) { } diff --git a/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java index 1ea4ed8460..e1e5a1e18c 100644 --- a/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java @@ -9,6 +9,7 @@ import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; +import se.llbit.chunky.world.java.JavaChunk; import se.llbit.chunky.world.region.ImposterCubicRegion; import se.llbit.nbt.CompoundTag; import se.llbit.nbt.ListTag; @@ -26,7 +27,7 @@ * * Represents an infinitely tall column of 16x16x16 Cubes */ -public class ImposterCubicChunk extends Chunk { +public class ImposterCubicChunk extends JavaChunk { private final CubicDimension dimension; public ImposterCubicChunk(ChunkPosition pos, Dimension dimension) { @@ -52,7 +53,7 @@ private Map> getCubeTags(Set request) { */ @Override public synchronized boolean loadChunk(@NotNull Mutable mutableChunkData, int yMin, int yMax) { - if (!shouldReloadChunk()) { + if (!shouldReload()) { return false; } diff --git a/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java b/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java index df2ad653ac..493c0c8e97 100644 --- a/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java +++ b/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java @@ -93,7 +93,7 @@ public PlayerEntityData(Tag player) { } } - static UUID getUuid(Tag playerTag) { + public static UUID getUuid(Tag playerTag) { final UUID uuid; long uuidHi; long uuidLo; diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index 51653becde..54785870cc 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -16,65 +16,50 @@ */ package se.llbit.chunky.world; -import se.llbit.log.Log; -import se.llbit.math.Vector3i; -import se.llbit.nbt.NamedTag; -import se.llbit.nbt.Tag; -import se.llbit.util.MinecraftText; -import se.llbit.util.UuidUtil; +import se.llbit.chunky.world.worldformat.WorldFormat; import se.llbit.util.annotation.NotNull; import java.io.*; import java.nio.file.Path; import java.util.*; -import java.util.stream.Collectors; -import java.util.zip.GZIPInputStream; /** - * The World class contains information about the currently viewed world. - * It has a map of all chunks in the world and is responsible for parsing - * chunks when needed. All rendering is done through the WorldRenderer class. + * The World class contains {@link Info metadata} about itself, and methods for querying and loading its dimensions. + * It also contains the {@link #currentDimension() currently loaded dimension} if there is one. * * @author Jesper Öqvist */ -public class World implements Comparable { - - /** The currently supported NBT version of level.dat files. */ - public static final int NBT_VERSION = 19133; +public abstract class World { + /** + * Metadata about a world. + */ + public record Info(String name, Path path, long lastModified, long seed, String gameMode, WorldFormat worldFormat) { + /** + * Chunky sees each valid {@link Info#path()} and {@link Info#worldFormat()} combination as a distinct world which + * can be selected by the user. + */ + public boolean isSameWorld(Info other) { + return this.worldFormat == other.worldFormat && this.path.equals(other.path); + } + } /** Default sea water level. */ public static final int SEA_LEVEL = 63; - /** Minimum level.dat data version of tall worlds (21w06a). */ - public static final int VERSION_21W06A = 2694; - public static final int VERSION_1_12_2 = 1343; - - private final File worldDirectory; + private final Info info; + @NotNull protected Dimension currentDimension; - private final String levelName; - private int gameMode = 0; - private final long seed; - - private int versionId; - - /** Timestamp for level.dat when player data was last loaded. */ - private long timestamp; - - private UUID singleplayerPlayerUuid; + protected World(Info info) { + this.info = info; + this.currentDimension = EmptyWorld.INSTANCE.currentDimension(); + } - /** - * @param levelName name of the world (not the world directory). - * @param worldDirectory Minecraft world directory. - * @param seed - * @param timestamp - */ - protected World(String levelName, File worldDirectory, long seed, long timestamp) { - this.levelName = levelName; - this.worldDirectory = worldDirectory; - this.seed = seed; - this.timestamp = timestamp; + /** Only for use by {@link EmptyWorld} */ + protected World(Info info, @NotNull EmptyDimension dimension) { + this.info = info; + this.currentDimension = dimension; } public enum LoggedWarnings { @@ -82,246 +67,40 @@ public enum LoggedWarnings { SILENT } - public void loadDimension(Dimension.Identifier dimensionId) { - currentDimension = loadDimension(this, this.worldDirectory, dimensionId, Collections.emptySet()); - currentDimension.reloadPlayerData(); - } - /** - * Parse player location and level name. + * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a + * dimension be loaded. * - * @return {@code true} if the world data was loaded + * @return List the viewable dimensions within the world. */ - public static World loadWorld(File worldDirectory, Dimension.Identifier dimensionId, LoggedWarnings warnings) { - if (worldDirectory == null) { - return EmptyWorld.INSTANCE; - } - String levelName = worldDirectory.getName(); // Default level name. - File worldFile = new File(worldDirectory, "level.dat"); - long modtime = worldFile.lastModified(); - try (FileInputStream fin = new FileInputStream(worldFile); - InputStream gzin = new GZIPInputStream(fin); - DataInputStream in = new DataInputStream(gzin)) { - Set request = new HashSet<>(); - request.add(".Data.version"); - request.add(".Data.Version.Id"); - request.add(".Data.RandomSeed"); - request.add(".Data.Player"); - request.add(".Data.singleplayer_uuid"); - request.add(".Data.LevelName"); - request.add(".Data.GameType"); - request.add(".Data.isCubicWorld"); - Map result = NamedTag.quickParse(in, request); - - Tag version = result.get(".Data.version"); - if (warnings == LoggedWarnings.NORMAL && version.intValue() != NBT_VERSION) { - Log.warnf("The world format for the world %s is not supported by Chunky.\n" + "Will attempt to load the world anyway.", - levelName); - } - Tag versionId = result.get(".Data.Version.Id"); - Tag player = result.get(".Data.Player"); - Tag spawnX = player.get("SpawnX"); - Tag spawnY = player.get("SpawnY"); - Tag spawnZ = player.get("SpawnZ"); - Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); - Tag gameType = result.get(".Data.GameType"); - Tag randomSeed = result.get(".Data.RandomSeed"); - levelName = MinecraftText.removeFormatChars(result.get(".Data.LevelName").stringValue(levelName)); - - long seed = randomSeed.longValue(0); - - Set playerEntities = getPlayerEntityData(worldDirectory, dimensionId, player); - - World world = new World(levelName, worldDirectory, seed, modtime); - world.gameMode = gameType.intValue(0); - world.versionId = versionId.intValue(); - if (singleplayerUuid.isIntArray(4)) { - world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); - } else if (!player.isError()) { - world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); - } - - Dimension dimension = loadDimension(world, worldDirectory, dimensionId, playerEntities); - - boolean haveSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); - if (haveSpawnPos) { - dimension.setSpawnPos(new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0))); - } - - world.currentDimension = dimension; - - return world; - } catch (FileNotFoundException e) { - if (warnings == LoggedWarnings.NORMAL) { - Log.infof("Could not find level.dat file for world %s!", levelName); - } - } catch (IOException e) { - if (warnings == LoggedWarnings.NORMAL) { - Log.infof("Could not read the level.dat file for world %s!", levelName); - } - } - return EmptyWorld.INSTANCE; - } - - @NotNull - private static Dimension loadDimension(World world, File worldDirectory, Dimension.Identifier dimensionId, Set playerEntities) { - File dimensionDirectory = Path.of(worldDirectory.getPath(), "dimensions", dimensionId.namespace(), dimensionId.name()).toFile(); - if (dimensionDirectory.exists()) { - // 26.1-snapshot-6 or later - return new Dimension(world, dimensionId, dimensionDirectory, playerEntities); - } - - dimensionDirectory = switch (dimensionId.getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here - case "minecraft:the_nether" -> new File(worldDirectory, "DIM-1"); - case "minecraft:the_end" -> new File(worldDirectory, "DIM1"); - default -> worldDirectory; - }; - if (new File(dimensionDirectory, "region3d").exists()) { - return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities); - } else { - return new Dimension(world, dimensionId, dimensionDirectory, playerEntities); - } - } - - @NotNull - private static Set getPlayerEntityData(File worldDirectory, Dimension.Identifier dimensionId, Tag player) { - Set playerEntities = new HashSet<>(); - if (!player.isError()) { - playerEntities.add(new PlayerEntityData(player)); - } - loadAdditionalPlayers(worldDirectory, playerEntities); - // Filter for the players only within the requested dimension - playerEntities = playerEntities.stream().filter(playerData -> playerData.dimension.equals(dimensionId)).collect(Collectors.toSet()); - return playerEntities; - } + public abstract Set getAvailableDimensions(); /** - * Reload player data for the current dimension. This method is not in Dimension because players are per-world, not per-dimension - * @return {@code true} if player data was reloaded. + * MUST be one of {@link #getAvailableDimensions()} + * @return The preferred default dimension of this world (typically the overworld) */ - synchronized boolean reloadPlayerData() { - if (worldDirectory == null) { - return false; - } - File worldFile = new File(worldDirectory, "level.dat"); - long lastModified = worldFile.lastModified(); - if (lastModified == timestamp) { - return false; - } - Log.infof("world %s: timestamp updated: reading player data", levelName); - timestamp = lastModified; - - try (FileInputStream fin = new FileInputStream(worldFile); - InputStream gzin = new GZIPInputStream(fin); - DataInputStream in = new DataInputStream(gzin)) { - Set request = new HashSet<>(); - request.add(".Data.Player"); - request.add(".Data.singleplayer_uuid"); - Map result = NamedTag.quickParse(in, request); - Tag player = result.get(".Data.Player"); - Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); - if (singleplayerUuid.isIntArray(4)) { - singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); - } else if (!player.isError()) { - singleplayerPlayerUuid = PlayerEntityData.getUuid(player); - } - - currentDimension.setPlayerEntities(getPlayerEntityData(worldDirectory, currentDimension.getDimensionId(), player)); - } catch (IOException e) { - Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", levelName); - return false; - } - return true; - } - - private static void loadAdditionalPlayers(File worldDirectory, Set playerEntities) { - loadPlayerData(new File(worldDirectory, "players"), playerEntities); - loadPlayerData(new File(worldDirectory, "playerdata"), playerEntities); - loadPlayerData(new File(new File(worldDirectory, "players"), "data"), playerEntities); // 26.1-snapshot-6 or later - } + public abstract Optional getDefaultDimension(); - private static void loadPlayerData(File playerdata, Set playerEntities) { - if (playerdata.isDirectory()) { - File[] players = playerdata.listFiles(); - if (players != null) { - for (File player : players) { - try (DataInputStream in = new DataInputStream( - new GZIPInputStream(new FileInputStream(player)))) { - playerEntities.add(new PlayerEntityData(NamedTag.read(in).unpack())); - } catch (IOException e) { - Log.infof("Could not read player data file '%s'", player.getAbsolutePath()); - } - } - } - } - } + /** + * @param dimensionId The dimension to load, guaranteed to be one of the dimensions previously returned by {@link #getAvailableDimensions()} + * @return The loaded dimension + */ + public abstract Dimension loadDimension(Dimension.Identifier dimensionId); /** - * @return The current dimension + * @return The current dimension or {@link EmptyDimension#INSTANCE} if there isn't one. */ + @NotNull public synchronized Dimension currentDimension() { return this.currentDimension; } - public Optional getSingleplayerPlayerUuid() { - return Optional.ofNullable(singleplayerPlayerUuid); - } - - /** - * @return The world directory - */ - public File getWorldDirectory() { - return worldDirectory; + public Info getInfo() { + return this.info; } @Override public String toString() { - return levelName + " (" + worldDirectory.getName() + ")"; - } - - /** The name of this world (not the world directory name). */ - public String levelName() { - return levelName; - } - - public int getVersionId() { - return versionId; - } - - /** - * @return true if the given directory exists and - * contains a level.dat file - */ - public static boolean isWorldDir(File worldDir) { - if (worldDir != null && worldDir.isDirectory()) { - File levelDat = new File(worldDir, "level.dat"); - return levelDat.exists() && levelDat.isFile(); - } - return false; - } - - /** - * @return String describing the game-mode of this world - */ - public String gameMode() { - return switch (gameMode) { - case 0 -> "Survival"; - case 1 -> "Creative"; - case 2 -> "Adventure"; - default -> "Unknown"; - }; - } - - @Override public int compareTo(World o) { - // Compares world names and directories. - return toString().compareToIgnoreCase(o.toString()); - } - - public long getSeed() { - return seed; - } - - public Date getLastModified() { - return new Date(this.worldDirectory.lastModified()); + return info.name + " (" + info.path.getFileName() + ")"; } /** @@ -330,7 +109,7 @@ public Date getLastModified() { * @return Resource pack file/directory or empty optional if this world has no bundled resource pack */ public Optional getResourcePack() { - for (File resourcepacksDirectory : new File[]{getWorldDirectory(), new File(getWorldDirectory(), "resourcepacks")}) { + for (File resourcepacksDirectory : new File[]{ info.path.toFile(), new File(info.path.toFile(), "resourcepacks")}) { if (resourcepacksDirectory.isDirectory()) { File resourcePack = new File(resourcepacksDirectory, "resources.zip"); if (resourcePack.isFile()) { diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java new file mode 100644 index 0000000000..c662f912e7 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java @@ -0,0 +1,364 @@ +package se.llbit.chunky.world.java; + +import se.llbit.chunky.block.legacy.LegacyBlocks; +import se.llbit.chunky.chunk.BlockPalette; +import se.llbit.chunky.chunk.ChunkData; +import se.llbit.chunky.chunk.ChunkLoadingException; +import se.llbit.chunky.chunk.EmptyChunkData; +import se.llbit.chunky.chunk.biome.BiomeDataFactory; +import se.llbit.chunky.map.BiomeLayer; +import se.llbit.chunky.map.IconLayer; +import se.llbit.chunky.map.SurfaceLayer; +import se.llbit.chunky.world.*; +import se.llbit.chunky.world.biome.ArrayBiomePalette; +import se.llbit.chunky.world.biome.BiomePalette; +import se.llbit.chunky.world.java.region.JavaRegion; +import se.llbit.chunky.world.region.Region; +import se.llbit.log.Log; +import se.llbit.math.QuickMath; +import se.llbit.nbt.CompoundTag; +import se.llbit.nbt.ListTag; +import se.llbit.nbt.SpecificTag; +import se.llbit.nbt.Tag; +import se.llbit.util.BitBuffer; +import se.llbit.util.Mutable; +import se.llbit.util.annotation.NotNull; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static se.llbit.util.NbtUtil.getTagFromNames; +import static se.llbit.util.NbtUtil.tagFromMap; + +public class JavaChunk extends Chunk { + public JavaChunk(ChunkPosition pos, Dimension dimension) { + super(pos, dimension); + } + + /** + * @param request fresh request set + * @return loaded data, or null if something went wrong + */ + private Map getChunkTags(Set request) throws ChunkLoadingException { + JavaRegion region = (JavaRegion) dimension.getRegion(position.getRegionPosition()); + Mutable timestamp = new Mutable<>(dataTimestamp); + Map chunkTags = region.getChunkTags(this.position, request, timestamp); + this.dataTimestamp = timestamp.get(); + return chunkTags; + } + + /** + * @param request fresh request set + * @return loaded data, or null if something went wrong + */ + private Map getEntityTags(Set request) throws ChunkLoadingException { + JavaRegion region = (JavaRegion) dimension.getRegion(position.getRegionPosition()); + return region.getEntityTags(this.position, request); + } + + @Override + public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { + if (!shouldReload()) { + return false; + } + + Set request = new HashSet<>(); + request.add(Chunk.DATAVERSION); + request.add(Chunk.LEVEL_SECTIONS); + request.add(Chunk.SECTIONS_POST_21W39A); + request.add(Chunk.LEVEL_BIOMES); + request.add(Chunk.BIOMES_POST_21W39A); + request.add(Chunk.LEVEL_HEIGHTMAP); + + Map dataMap; + try { + dataMap = getChunkTags(request); + } catch (ChunkLoadingException e) { // we don't want to crash the map view if a chunk fails to load, so we warn the user + Log.warn(String.format("Failed to load chunk %s", position), e); + return false; + } + // TODO: improve error handling here. + if (dataMap == null) { + return false; + } + Tag data = tagFromMap(dataMap); + + surfaceTimestamp = dataTimestamp; + version = getChunkVersion(data); + HeightRange chunkBounds = inclusiveChunkBounds(data); + chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.min(), chunkBounds.max())); + loadSurface(data, chunkData.get(), yMin, yMax); + biomesTimestamp = dataTimestamp; + + dimension.chunkUpdated(position); + return true; + } + + private void loadSurface(@NotNull Tag data, ChunkData chunkData, int yMin, int yMax) { + if (data == null) { + surface = IconLayer.CORRUPT; + return; + } + + Heightmap heightmap = dimension.getHeightmap(); + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + if (sections.isList()) { + if (version == ChunkVersion.PRE_FLATTENING || version == ChunkVersion.POST_FLATTENING) { + BiomePalette biomePalette = new ArrayBiomePalette(); + BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, yMin, yMax); + biomes = new BiomeLayer(chunkData, biomePalette); + + BlockPalette palette = new BlockPalette(); + palette.unsynchronize(); //only this RegionParser will use this palette + loadBlockData(data, chunkData, palette, yMin, yMax); + + int[] heightmapData = extractHeightmapData(data, chunkData); + updateHeightmap(heightmap, position, chunkData, heightmapData, palette, yMax); + + surface = new SurfaceLayer(dimension.getDimensionId(), chunkData, palette, biomePalette, yMin, yMax, heightmapData); + queueTopography(); + } + } else { + surface = IconLayer.CORRUPT; + } + } + + private int[] extractHeightmapData(@NotNull Tag data, ChunkData chunkData) { + Tag heightmapTag = data.get(LEVEL_HEIGHTMAP); + if (heightmapTag.isIntArray(X_MAX * Z_MAX)) { + return heightmapTag.intArray(); + } else { + int[] fallback = new int[X_MAX * Z_MAX]; + for (int i = 0; i < fallback.length; ++i) { + fallback[i] = chunkData.maxY(); + } + return fallback; + } + } + + protected boolean shouldReload() { + int timestamp = Integer.MAX_VALUE; + timestamp = Math.min(timestamp, surfaceTimestamp); + timestamp = Math.min(timestamp, biomesTimestamp); + if (timestamp == 0) { + return true; + } + Region region = dimension.getRegion(position.getRegionPosition()); + return region.chunkChangedSince(position, timestamp); + } + + /** Detect Minecraft version that generated the chunk. */ + private static ChunkVersion getChunkVersion(@NotNull Tag data) { + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + if (sections.isList()) { + for (SpecificTag section : sections.asList()) { + if (!section.get("Palette").isList()) { + if (section.get("Blocks").isByteArray(SECTION_BYTES)) { + return ChunkVersion.PRE_FLATTENING; + } + } + } + return ChunkVersion.POST_FLATTENING; + } + return ChunkVersion.UNKNOWN; + } + + private static void loadBlockData(@NotNull Tag data, @NotNull ChunkData chunkData, + BlockPalette blockPalette, int minY, int maxY) { + + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + if (sections.isList()) { + for (SpecificTag section : sections.asList()) { + Tag yTag = section.get("Y"); + int sectionY = yTag.byteValue(); + int sectionMinBlockY = sectionY << 4; + + if(sectionY < minY >> 4 || sectionY-1 > (maxY >> 4)+1) + continue; //skip parsing sections that are outside requested bounds + + Tag blockPaletteTag = getTagFromNames(section, "Palette", "block_states\\palette"); + if (blockPaletteTag.isList()) { + ListTag localBlockPalette = blockPaletteTag.asList(); + // Bits per block: + int bpb = 4; + if (localBlockPalette.size() > 16) { + bpb = QuickMath.log2(QuickMath.nextPow2(localBlockPalette.size())); + } + + int dataSize = (4096 * bpb) / 64; + Tag blockStates = getTagFromNames(section, "BlockStates", "block_states\\data"); + + if (blockStates.isLongArray(dataSize)) { + // since 20w17a, block states are aligned to 64-bit boundaries, so there are 64 % bpb + // unused bits per block state; if so, the array is longer than the expected data size + boolean isAligned = data.get(DATAVERSION).intValue() >= DATAVERSION_20W17A; + if (isAligned) { + // entries are 64-bit-padded, re-calculate the bits per block + // this is the dataSize calculation from above reverted, we know the actual data size + bpb = blockStates.longArray().length / 64; + } + + int[] subpalette = new int[localBlockPalette.size()]; + int paletteIndex = 0; + for (Tag item : localBlockPalette.asList()) { + subpalette[paletteIndex] = blockPalette.put(item); + paletteIndex += 1; + } + BitBuffer buffer = new BitBuffer(blockStates.longArray(), bpb, isAligned); + for (int y = 0; y < SECTION_Y_MAX; y++) { + int blockY = sectionMinBlockY + y; + for (int z = 0; z < Z_MAX; z++) { + for(int x = 0; x < X_MAX; x++) { + int b0 = buffer.read(); + if (b0 < subpalette.length) { + chunkData.setBlockAt(x, blockY, z, subpalette[b0]); + } + } + } + } + } else { + // Single block palette + if (localBlockPalette.size() == 1) { + // Check it is not air block + int block = blockPalette.put(localBlockPalette.get(0)); + if (block != blockPalette.airId) { + // Set the entire section + for (int y = 0; y < SECTION_Y_MAX; y++) { + int blockY = sectionMinBlockY + y; + for (int z = 0; z < Z_MAX; z++) { + for(int x = 0; x < X_MAX; x++) { + chunkData.setBlockAt(x, blockY, z, block); + } + } + } + } + } + } + } else { + int yOffset = sectionY & 0xFF; + + Tag dataTag = section.get("Data"); + byte[] blockDataBytes = new byte[(Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX) / 2]; + if (dataTag.isByteArray(SECTION_HALF_NIBBLES)) { + System.arraycopy(dataTag.byteArray(), 0, blockDataBytes, SECTION_HALF_NIBBLES * yOffset, + SECTION_HALF_NIBBLES); + } + + Tag blocksTag = section.get("Blocks"); + if (blocksTag.isByteArray(SECTION_BYTES)) { + byte[] blocksBytes = new byte[Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX]; + System.arraycopy(blocksTag.byteArray(), 0, blocksBytes, SECTION_BYTES * yOffset, + SECTION_BYTES); + + int offset = SECTION_BYTES * yOffset; + for (int y = 0; y < SECTION_Y_MAX; y++) { + int blockY = sectionMinBlockY + y; + for (int z = 0; z < Z_MAX; z++) { + for (int x = 0; x < X_MAX; x++) { + chunkData.setBlockAt(x, blockY, z, blockPalette.put( + LegacyBlocks.getTag(offset, blocksBytes, blockDataBytes))); + offset += 1; + } + } + } + } + } + } + } + } + + @Override + public synchronized void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { + Set request = new HashSet<>(); + request.add(DATAVERSION); + request.add(LEVEL_SECTIONS); + request.add(SECTIONS_POST_21W39A); + request.add(LEVEL_BIOMES); + request.add(BIOMES_POST_21W39A); + request.add(LEVEL_ENTITIES); + request.add(LEVEL_TILEENTITIES); + request.add(BLOCK_ENTITIES_POST_21W43A); + Map dataMap = getChunkTags(request); + // TODO: improve error handling here. + if (dataMap == null) { + throw new ChunkLoadingException(String.format("Got null data for chunk %s", this.position)); + } + Tag data = tagFromMap(dataMap); + + int dataVersion = data.get(DATAVERSION).intValue(); + + HeightRange chunkBounds = inclusiveChunkBounds(data); + + if(reuseChunkData.get() == null || reuseChunkData.get() instanceof EmptyChunkData) { + reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.min(), chunkBounds.max())); + } else { + reuseChunkData.get().clear(); + } + ChunkData chunkData = reuseChunkData.get(); //unwrap mutable, for ease of use + + version = getChunkVersion(data); + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + Tag entitiesTag = data.get(LEVEL_ENTITIES); + Tag tileEntitiesTag = getTagFromNames(data, LEVEL_TILEENTITIES, BLOCK_ENTITIES_POST_21W43A); + + BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, minY, maxY); + if (sections.isList()) { + loadBlockData(data, chunkData, palette, minY, maxY); + + if (entitiesTag.isList()) { + for (SpecificTag tag : (ListTag) entitiesTag) { + if (tag.isCompoundTag()) + chunkData.addEntity((CompoundTag) tag); + } + } + + if (tileEntitiesTag.isList()) { + for (SpecificTag tag : (ListTag) tileEntitiesTag) { + if (tag.isCompoundTag()) + chunkData.addTileEntity((CompoundTag) tag); + } + } + } + + // post 20w45A entities + if (dataVersion >= DATAVERSION_20W45A) { + Set entitiesRequest = new HashSet<>(); + entitiesRequest.add(ENTITIES_POST_20W45A); + + Map entitiesMap = getEntityTags(entitiesRequest); + if (entitiesMap != null) { + entitiesTag = entitiesMap.get(".Entities"); + if (entitiesTag.isList()) { + for (SpecificTag tag : (ListTag) entitiesTag) { + if (tag.isCompoundTag()) + chunkData.addEntity((CompoundTag) tag); + } + } + } + } + } + + /** + * @return The min and max blockY for a given section array + */ + private HeightRange inclusiveChunkBounds(Tag chunkData) { + Tag sections = getTagFromNames(chunkData, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + int minSectionY = Integer.MAX_VALUE; + int maxSectionY = Integer.MIN_VALUE; + if (sections.isList()) { + for (SpecificTag section : sections.asList()) { + byte sectionY = (byte) section.get("Y").byteValue(); + if (sectionY < minSectionY) { + minSectionY = sectionY; + } + if (sectionY > maxSectionY) { + maxSectionY = sectionY; + } + } + } + + return new HeightRange(minSectionY << 4, (maxSectionY << 4) + 15); + } + +} diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java new file mode 100644 index 0000000000..dcfb177990 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -0,0 +1,139 @@ +package se.llbit.chunky.world.java; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import se.llbit.chunky.map.MapView; +import se.llbit.chunky.map.WorldMapLoader; +import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.region.JavaRegion; +import se.llbit.chunky.world.java.region.JavaRegionChangeWatcher; +import se.llbit.chunky.world.region.*; +import se.llbit.math.Vector3; +import se.llbit.math.Vector3i; +import se.llbit.util.annotation.Nullable; + +import java.io.File; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public class JavaDimension extends Dimension { + protected final JavaWorld world; + protected final Long2ObjectMap regionMap = new Long2ObjectOpenHashMap<>(); + + /** + * @param world + * @param dimensionId + * @param dimensionDirectory Minecraft world directory. + * @param playerEntities + */ + protected JavaDimension(JavaWorld world, Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + super(dimensionId, dimensionDirectory, playerEntities, spawnPos); + this.world = world; + } + + @Override + public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { + return new JavaRegionChangeWatcher(worldMapLoader, mapView); + } + + @Override + public Region createRegion(RegionPosition pos) { + return new JavaRegion(pos, this); + } + + /** + * Set the region for the given position. + */ + public synchronized void setRegion(RegionPosition pos, Region region) { + regionMap.put(pos.getLong(), region); + } + + @Override + public synchronized Region getRegion(RegionPosition pos) { + return regionMap.computeIfAbsent(pos.getLong(), p -> { + // check if the region is present in the world directory + Region region = EmptyRegion.instance; + if (hasRegion(pos)) { + region = createRegion(pos); + } + return region; + }); + } + + @Override + public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { + return getRegion(pos); + } + + @Override + public boolean hasRegion(RegionPosition pos) { + File regionFile = new File(getRegionDirectory(), pos.getMcaName()); + return regionFile.exists(); + } + + @Override + public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) { + return this.hasRegion(pos); + } + + @Override + public HeightRange heightRange() { + return this.world.versionId >= JavaWorld.VERSION_21W06A ? + new HeightRange(-64, 320) : + new HeightRange(0, 256); + } + + + @Override + public synchronized Chunk getChunk(ChunkPosition pos) { + return getRegion(pos.getRegionPosition()).getChunk(pos); + } + + @Override + public synchronized boolean reloadPlayerData() { + boolean changed = this.world.reloadPlayerData(); + if (changed) { + this.playerEntities.clear(); + this.playerEntities.addAll(this.world.playerEntities.stream() + .filter(player -> player.dimension.equals(this.dimensionId)) + .collect(Collectors.toSet())); + } + return changed; + } + + @Override + public synchronized Optional getPlayerPos() { + if (!this.playerEntities.isEmpty()) { + return world.getSingleplayerPlayerUuid() + .flatMap(uuid -> this.playerEntities.stream() + .filter(player -> player.uuid.equals(uuid)) + .map(pos -> new Vector3(pos.x, pos.y, pos.z)) + .findFirst()); + } else { + return Optional.empty(); + } + } + + /** + * Called when a new region has been discovered by the region parser. + */ + public void regionDiscovered(RegionPosition pos) { + synchronized (this) { + regionMap.computeIfAbsent(pos.getLong(), p -> createRegion(pos)); + } + } + + /** + * @return File object pointing to the region file directory + */ + public synchronized File getRegionDirectory() { + return dimensionDirectory.resolve("region").toFile(); + } + + @Override + public String getName() { + return dimensionDirectory.getFileName().toString(); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java new file mode 100644 index 0000000000..de0fe246fd --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -0,0 +1,265 @@ +package se.llbit.chunky.world.java; + +import se.llbit.chunky.world.*; +import se.llbit.log.Log; +import se.llbit.math.Vector3i; +import se.llbit.nbt.NamedTag; +import se.llbit.nbt.Tag; +import se.llbit.util.MinecraftText; +import se.llbit.util.UuidUtil; +import se.llbit.util.annotation.NotNull; +import se.llbit.util.annotation.Nullable; + +import java.io.*; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.zip.GZIPInputStream; + +public class JavaWorld extends World { + /** The currently supported NBT version of level.dat files. */ + public static final int NBT_VERSION = 19133; + + /** Minimum level.dat data version of tall worlds (21w06a). */ + public static final int VERSION_21W06A = 2694; + public static final int VERSION_1_12_2 = 1343; + + protected final int versionId; + + /** + * In a java world spawn position is per-world and not per-dimension, so we store it here. + */ + protected final Vector3i spawnPos; + + /** + * In a java world player data is per-world and not per-dimension, so we store it here. + */ + protected final Collection playerEntities; + + protected UUID singleplayerPlayerUuid; + + /** Timestamp of when player data was last loaded. */ + protected long playerDataTimestamp; + + protected JavaWorld(Info info, long timestamp, int versionId, Set playerEntities, Vector3i spawnPos) { + super(info); + this.versionId = versionId; + this.playerEntities = playerEntities; + this.spawnPos = spawnPos; + this.playerDataTimestamp = timestamp; + } + + public static Optional loadWorldInfo(@NotNull Path worldDirectory, LoggedWarnings warnings, JavaWorldFormat format) { + return readWorldData(worldDirectory, warnings, data -> { + Tag gameType = data.tags.get(".Data.GameType"); + String gameMode = switch (gameType.intValue(0)) { + case 0 -> "Survival"; + case 1 -> "Creative"; + case 2 -> "Adventure"; + default -> "Unknown"; + }; + Tag randomSeed = data.tags.get(".Data.RandomSeed"); + + String levelName = MinecraftText.removeFormatChars(data.tags.get(".Data.LevelName").stringValue(data.levelName)); + + long seed = randomSeed.longValue(0); + + return new Info(levelName, worldDirectory, data.modTime, seed, gameMode, format); + }); + } + + public static World loadWorld(Info info, LoggedWarnings warnings) { + return readWorldData(info.path(), warnings, data -> { + Tag versionId = data.tags.get(".Data.Version.Id"); + + Tag player = data.tags.get(".Data.Player"); + Set playerEntities = getPlayerEntityData(info.path(), player); + + Tag spawnX = player.get("SpawnX"); + Tag spawnY = player.get("SpawnY"); + Tag spawnZ = player.get("SpawnZ"); + boolean hasSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); + Vector3i spawnPos = new Vector3i(); + if (hasSpawnPos) { + spawnPos = new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0)); + } + + JavaWorld world = new JavaWorld(info, data.modTime, versionId.intValue(), playerEntities, spawnPos); + + Tag singleplayerUuid = data.tags.get(".Data.singleplayer_uuid"); + if (singleplayerUuid.isIntArray(4)) { + world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); + } else if (!player.isError()) { + world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); + } + + return (World) world; + }).orElse(EmptyWorld.INSTANCE); + } + + @Override + public Set getAvailableDimensions() { + return Set.of(Dimension.Identifier.OVERWORLD, // TODO: return the actual set of dimensions on disk. + Dimension.Identifier.THE_NETHER, + Dimension.Identifier.THE_END + ); + } + + @Override + public Optional getDefaultDimension() { + return Optional.empty(); + } + + @Override + public Dimension loadDimension(Dimension.Identifier dimensionId) { + currentDimension = loadDimension( + this, + getInfo().path(), + dimensionId, + this.playerEntities.stream().filter(player -> player.dimension.equals(dimensionId)).collect(Collectors.toSet()), + this.spawnPos + ); + currentDimension.reloadPlayerData(); + return currentDimension; + } + + @NotNull + private static Dimension loadDimension(JavaWorld world, Path worldDirectory, Dimension.Identifier dimensionId, Set playerEntities, @Nullable Vector3i spawnPos) { + Path dimensionDirectory = worldDirectory.resolve("dimensions").resolve(dimensionId.namespace()).resolve(dimensionId.name()); + if (Files.exists(dimensionDirectory)) { + // 26.1-snapshot-6 or later + return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); + } + + dimensionDirectory = switch (dimensionId.getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here + case "minecraft:the_nether" -> worldDirectory.resolve("DIM-1"); + case "minecraft:the_end" -> worldDirectory.resolve("DIM1"); + default -> worldDirectory; + }; + if (Files.isDirectory(dimensionDirectory.resolve("region3d"))) { + return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); + } else { + return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); + } + } + + public Optional getSingleplayerPlayerUuid() { + return Optional.ofNullable(singleplayerPlayerUuid); + } + + @NotNull + private static Set getPlayerEntityData(Path worldDirectory, Tag player) { + Set playerEntities = new HashSet<>(); + if (!player.isError()) { + playerEntities.add(new PlayerEntityData(player)); + } + loadAdditionalPlayers(worldDirectory, playerEntities); + return playerEntities; + } + + private static void loadAdditionalPlayers(Path worldDirectory, Set playerEntities) { + loadPlayerData(worldDirectory.resolve("players"), playerEntities); + loadPlayerData(worldDirectory.resolve("playerdata"), playerEntities); + loadPlayerData(worldDirectory.resolve("players").resolve("data"), playerEntities); // 26.1-snapshot-6 or later + } + + private static void loadPlayerData(Path playerDataDirectory, Set playerEntities) { + if (Files.isDirectory(playerDataDirectory)) { + try (DirectoryStream paths = Files.newDirectoryStream(playerDataDirectory)) { + for (Path player : paths) { + try (DataInputStream in = new DataInputStream( + new GZIPInputStream(new FileInputStream(player.toFile())))) { + playerEntities.add(new PlayerEntityData(NamedTag.read(in).unpack())); + } catch (IOException e) { + Log.infof("Could not read player data file '%s'", player.toAbsolutePath()); + } + } + } catch (IOException e) { + Log.infof("Could not list player data directory '%s'", playerDataDirectory.toAbsolutePath()); + } + } + } + + /** + * Reload player data for the current dimension. This method is not in Dimension because players are per-world, not per-dimension + * @return {@code true} if player data was reloaded. + */ + synchronized boolean reloadPlayerData() { + Path worldFile = getInfo().path().resolve("level.dat"); + long lastModified; + try { + lastModified = Files.getLastModifiedTime(worldFile).toMillis(); + } catch (IOException e) { + return false; + } + if (lastModified == playerDataTimestamp) { + return false; + } + Log.infof("world %s: timestamp updated: reading player data", getInfo().name()); + playerDataTimestamp = lastModified; + + try (FileInputStream fin = new FileInputStream(worldFile.toFile()); + InputStream gzin = new GZIPInputStream(fin); + DataInputStream in = new DataInputStream(gzin)) { + Set request = new HashSet<>(); + request.add(".Data.Player"); + request.add(".Data.singleplayer_uuid"); + Map result = NamedTag.quickParse(in, request); + Tag player = result.get(".Data.Player"); + Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); + if (singleplayerUuid.isIntArray(4)) { + singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); + } else if (!player.isError()) { + singleplayerPlayerUuid = PlayerEntityData.getUuid(player); + } + + this.playerEntities.clear(); + this.playerEntities.addAll(getPlayerEntityData(getInfo().path(), player)); + } catch (IOException e) { + Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", getInfo().name()); + return false; + } + return true; + } + + private record WorldData(String levelName, long modTime, Map tags) {} + private static Optional readWorldData(@NotNull Path worldDirectory, LoggedWarnings warnings, Function consumer) { + String levelName = worldDirectory.getFileName().toString(); + Path levelDat = worldDirectory.resolve("level.dat"); + try (FileInputStream fin = new FileInputStream(levelDat.toFile()); + InputStream gzin = new GZIPInputStream(fin); + DataInputStream in = new DataInputStream(gzin)) { + long modtime = Files.getLastModifiedTime(levelDat).toMillis(); + Set request = new HashSet<>(); + request.add(".Data.version"); + request.add(".Data.Version.Id"); + request.add(".Data.RandomSeed"); + request.add(".Data.Player"); + request.add(".Data.singleplayer_uuid"); + request.add(".Data.LevelName"); + request.add(".Data.GameType"); + + Map result = NamedTag.quickParse(in, request); + + Tag version = result.get(".Data.version"); + if (warnings == LoggedWarnings.NORMAL && version.intValue() != NBT_VERSION) { + Log.warnf("The world format for the world %s is not supported by Chunky.\n" + "Will attempt to load the world anyway.", + levelName); + } + + return Optional.of(consumer.apply(new WorldData(levelName, modtime, result))); + } catch (FileNotFoundException e) { + if (warnings == LoggedWarnings.NORMAL) { + Log.infof("Could not find level.dat file for world %s!", levelName); + } + } catch (IOException e) { + if (warnings == LoggedWarnings.NORMAL) { + Log.infof("Could not read the level.dat file for world %s!", levelName); + } + } + return Optional.empty(); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java new file mode 100644 index 0000000000..9de5a4fcf1 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java @@ -0,0 +1,58 @@ +package se.llbit.chunky.world.java; + +import se.llbit.chunky.world.World; +import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.util.annotation.NotNull; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +public class JavaWorldFormat implements WorldFormat { + public static final String NAME = "Java (Anvil)"; + public static final String ID = "JAVA_ANVIL"; + + @Override + public String getName() { + return NAME; + } + + @Override + public String getDescription() { + return "The Minecraft world format for Java worlds since 1.2.1 (12w07a)"; + } + + @Override + public String getId() { + return ID; + } + + @Override + public boolean isValid(@NotNull Path path) { + if (Files.isDirectory(path)) { + Path levelDat = path.resolve("level.dat"); + return Files.exists(levelDat) && Files.isRegularFile(levelDat); + } + return false; + } + + @NotNull + @Override + public Optional getWorldInfo(@NotNull Path path) { + if (!Files.isDirectory(path)) { + return Optional.empty(); + } + Path levelDat = path.resolve("level.dat"); + if (!Files.exists(levelDat) || !Files.isRegularFile(levelDat)) { + return Optional.empty(); + } + + return JavaWorld.loadWorldInfo(path, World.LoggedWarnings.SILENT, this); + } + + @NotNull + @Override + public World loadWorld(@NotNull World.Info info) { + return JavaWorld.loadWorld(info, World.LoggedWarnings.NORMAL); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegion.java similarity index 95% rename from chunky/src/java/se/llbit/chunky/world/region/MCRegion.java rename to chunky/src/java/se/llbit/chunky/world/java/region/JavaRegion.java index e6cee2dda4..606f8c539b 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java +++ b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegion.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with Chunky. If not, see . */ -package se.llbit.chunky.world.region; +package se.llbit.chunky.world.java.region; import java.io.*; import java.util.Iterator; @@ -24,6 +24,10 @@ import se.llbit.chunky.chunk.ChunkLoadingException; import se.llbit.chunky.plugin.PluginApi; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.JavaChunk; +import se.llbit.chunky.world.java.JavaDimension; +import se.llbit.chunky.world.region.ChunkReadException; +import se.llbit.chunky.world.region.Region; import se.llbit.log.Log; import se.llbit.nbt.ErrorTag; import se.llbit.nbt.NamedTag; @@ -42,19 +46,8 @@ * * @author Jesper Öqvist */ -public class MCRegion implements Region { - - /** - * Region X chunk width - */ - public static final int CHUNKS_X = 32; - - /** - * Region Z chunk width - */ - public static final int CHUNKS_Z = 32; - - private static final int NUM_CHUNKS = CHUNKS_X * CHUNKS_Z; +public class JavaRegion implements Region { + private static final int NUM_CHUNKS = Region.CHUNKS_X * Region.CHUNKS_Z; /** * Sector size in bytes. @@ -63,7 +56,7 @@ public class MCRegion implements Region { private final Chunk[] chunks = new Chunk[NUM_CHUNKS]; private final RegionPosition position; - private final Dimension dimension; + private final JavaDimension dimension; private final String fileName; private long regionFileTime = 0; private final int[] chunkTimestamps = new int[NUM_CHUNKS]; @@ -80,12 +73,12 @@ private static int getMCAChunkIndex(ChunkPosition chunkPos) { * * @param pos the region position */ - public MCRegion(RegionPosition pos, Dimension dimension) { + public JavaRegion(RegionPosition pos, JavaDimension dimension) { this.dimension = dimension; fileName = pos.getMcaName(); position = pos; - for (int z = 0; z < CHUNKS_Z; ++z) { - for (int x = 0; x < CHUNKS_X; ++x) { + for (int z = 0; z < Region.CHUNKS_Z; ++z) { + for (int x = 0; x < Region.CHUNKS_X; ++x) { chunks[getMCAChunkIndex(x, z)] = EmptyChunk.INSTANCE; } } @@ -156,7 +149,7 @@ public synchronized void parse(int minY, int maxY) { int loc = file.readInt(); if (loc != 0) { if (chunk.isEmpty()) { - chunk = new Chunk(pos, dimension); + chunk = new JavaChunk(pos, dimension); setChunk(pos, chunk); } } else { diff --git a/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java similarity index 75% rename from chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java rename to chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java index b3e9e3bcc0..887f9c1b1b 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java +++ b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java @@ -14,24 +14,26 @@ * You should have received a copy of the GNU General Public License * along with Chunky. If not, see . */ -package se.llbit.chunky.world.region; +package se.llbit.chunky.world.java.region; import javafx.application.Platform; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.map.MapView; import se.llbit.chunky.map.WorldMapLoader; -import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.ChunkView; -import se.llbit.chunky.world.Dimension; +import se.llbit.chunky.world.HeightRange; import se.llbit.chunky.world.RegionPosition; +import se.llbit.chunky.world.java.JavaDimension; +import se.llbit.chunky.world.region.Region; +import se.llbit.chunky.world.region.RegionChangeWatcher; /** * Monitors filesystem for changes to region files. * * @author Jesper Öqvist */ -public class MCRegionChangeWatcher extends RegionChangeWatcher { - public MCRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { +public class JavaRegionChangeWatcher extends RegionChangeWatcher { + public JavaRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { super(loader, mapView, "Region Refresher"); } @@ -39,7 +41,8 @@ public MCRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { try { while (!isInterrupted()) { sleep(3000); - Dimension dimension = mapLoader.getWorld().currentDimension(); + // RegionChangeWatcher is only created by JavaDimension, so this cast is always safe. + JavaDimension dimension = (JavaDimension) mapLoader.getWorld().currentDimension(); if (dimension.reloadPlayerData()) { if (PersistentSettings.getFollowPlayer()) { Platform.runLater(() -> dimension.getPlayerPos().ifPresent(mapView::panTo)); @@ -49,9 +52,10 @@ public MCRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { for (int rx = theView.prx0; rx <= theView.prx1; ++rx) { for (int rz = theView.prz0; rz <= theView.prz1; ++rz) { RegionPosition pos = new RegionPosition(rx, rz); - Region region = dimension.getRegionWithinRange(pos, theView.yMin, theView.yMax); + HeightRange heightRange = new HeightRange(theView.yMin, theView.yMax); + Region region = dimension.getRegionWithinRange(pos, heightRange); if (region.isEmpty()) { - if (dimension.regionExistsWithinRange(pos, theView.yMin, theView.yMax)) { + if (dimension.hasRegionWithinRange(pos, heightRange)) { region = dimension.createRegion(pos); } dimension.setRegion(pos, region); diff --git a/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java b/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java index 436abd9507..84084a277c 100644 --- a/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java +++ b/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java @@ -18,7 +18,7 @@ import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.RegionPosition; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; import java.util.Collection; @@ -33,8 +33,8 @@ default void chunkUpdated(ChunkPosition chunkPosition) {} default void regionChunksUpdated(RegionPosition region) { int minChunkX = region.x << 5; int minChunkZ = region.z << 5; - for (int chunkX = minChunkX; chunkX < minChunkX + MCRegion.CHUNKS_X; chunkX++) { - for (int chunkZ = minChunkZ; chunkZ < minChunkZ + MCRegion.CHUNKS_Z; chunkZ++) { + for (int chunkX = minChunkX; chunkX < minChunkX + Region.CHUNKS_X; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ < minChunkZ + Region.CHUNKS_Z; chunkZ++) { this.chunkUpdated(new ChunkPosition(chunkX, chunkZ)); } } diff --git a/chunky/src/java/se/llbit/chunky/world/region/Region.java b/chunky/src/java/se/llbit/chunky/world/region/Region.java index f12662ef1e..3cd29239d3 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/Region.java +++ b/chunky/src/java/se/llbit/chunky/world/region/Region.java @@ -6,6 +6,15 @@ import se.llbit.chunky.world.RegionPosition; public interface Region extends Iterable { + /** + * Region X chunk width + */ + int CHUNKS_X = 32; + /** + * Region Z chunk width + */ + int CHUNKS_Z = 32; + /** * @return Chunk at (x, z) */ diff --git a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java index b25f0771c0..e47c641179 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java +++ b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java @@ -61,7 +61,7 @@ public RegionParser(WorldMapLoader loader, RegionQueue queue, MapView mapView) { ChunkView map = mapView.getMapView(); if (map.isRegionVisible(position)) { Dimension dimension = mapLoader.getWorld().currentDimension(); - Region region = dimension.getRegionWithinRange(position, mapView.getYMin(), mapView.getYMax()); + Region region = dimension.getRegionWithinRange(position, new HeightRange(mapView.getYMin(), mapView.getYMax())); region.parse(mapView.getYMin(), mapView.getYMax()); Mutable chunkData = new Mutable<>(null); for (Chunk chunk : region) { diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java new file mode 100644 index 0000000000..3a4448fbff --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -0,0 +1,51 @@ +package se.llbit.chunky.world.worldformat; + +import se.llbit.chunky.world.World; +import se.llbit.util.Registerable; +import se.llbit.util.annotation.NotNull; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +/** + * A {@link WorldFormat} determines whether paths on disk are valid for its world type, and can then load that + * {@link World} into chunky. + * + *

Implementations should be stateless, and never cache world validity.

+ * + *

Implementations will be queried for many different paths via {@link #isValid(Path)}. + * The same {@link Path} is likely to be checked more than once over the lifetime of the {@link WorldFormat}

+ */ +public interface WorldFormat extends Registerable { + /** + * Determine whether a {@link Path} is valid for this {@link WorldFormat} + * + *

This method will be called on every possible world directory + * (typically this is every directory in `.minecraft/saves`).

+ * + * @param path The path to the world. + * @return Whether this is a valid world under this world format. + */ + boolean isValid(@NotNull Path path); + + /** + * Load metadata about a world + * + *

Calls to this method do not indicate that any blocks will be loaded from the world. As such implementations + * should do minimal work to load the metadata for a world.

+ * + * @param path The path to the world + * @return The {@link World.Info}, if it was created successfully. + */ + @NotNull Optional getWorldInfo(@NotNull Path path); + + /** + * Load the world represented by the given world info. + * + * @param info The world info. + * @return The loaded world, or an empty world if the world was not loadable. + * @throws IOException When something goes wrong when loading the world. Will be reported to the user. + */ + @NotNull World loadWorld(@NotNull World.Info info) throws IOException; +} \ No newline at end of file diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java new file mode 100644 index 0000000000..3df89315d6 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java @@ -0,0 +1,56 @@ +package se.llbit.chunky.world.worldformat; + +import se.llbit.chunky.world.EmptyWorld; +import se.llbit.chunky.world.World; +import se.llbit.chunky.world.java.JavaWorldFormat; +import se.llbit.log.Log; +import se.llbit.util.annotation.NotNull; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +public class WorldFormats { + /** + * Uses an ordered hashmap to guarantee a consistent encounter order (and that {@link JavaWorldFormat} is checked first) + */ + private static final LinkedHashMap worldFormatsById = new LinkedHashMap<>(); + + public static void addWorldFormat(WorldFormat worldFormat) { + worldFormatsById.put(worldFormat.getId(), worldFormat); + } + + public static Map getWorldFormats() { + return Collections.unmodifiableMap(worldFormatsById); + } + + public static Optional getWorldFormat(String id) { + return Optional.ofNullable(worldFormatsById.get(id)); + } + + static { + addWorldFormat(new JavaWorldFormat()); + } + + @NotNull + public static Collection getInfos(Path path) { + return getWorldFormats().values().stream() + .filter(format -> format.isValid(path)) + .map(format -> format.getWorldInfo(path)) + .flatMap(Optional::stream) + .collect(Collectors.toList()); + } + + @NotNull + public static World createWorld(@NotNull World.Info info) { + try { + return info.worldFormat().loadWorld(info); + } catch (IOException e) { + Log.error(String.format("An error occurred when trying to load a world using format `%s` from %s", + info.worldFormat().getName(), info.path().toAbsolutePath()), e + ); + } + return EmptyWorld.INSTANCE; + } +} diff --git a/lib/src/se/llbit/chunky/PersistentSettings.java b/lib/src/se/llbit/chunky/PersistentSettings.java index b5758e0035..9f7e371285 100644 --- a/lib/src/se/llbit/chunky/PersistentSettings.java +++ b/lib/src/se/llbit/chunky/PersistentSettings.java @@ -190,6 +190,16 @@ public static File getLastWorld() { return lastWorld.isEmpty() ? null : new File(lastWorld); } + public static void setLastWorldFormat(String worldFormat) { + settings.setString("lastWorldFormat", worldFormat); + save(); + } + + /** @return the world format of the previously loaded world. */ + public static String getLastWorldFormat() { + return settings.getString("lastWorldFormat", ""); + } + public static void setSkinDirectory(File directory) { settings.setString("skinDirectory", directory.getAbsolutePath()); save();