diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 63bb74512e..a5b3e7840c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -126,6 +126,7 @@ public enum SVG { WARNING("M1 21 12 2 23 21H1ZM4.45 19H19.55L12 6 4.45 19ZM12 18Q12.425 18 12.7125 17.7125T13 17Q13 16.575 12.7125 16.2875T12 16Q11.575 16 11.2875 16.2875T11 17Q11 17.425 11.2875 17.7125T12 18ZM11 15H13V10H11V15ZM12 12.5Z"), WB_SUNNY("M11 4V1H13V4H11ZM11 23V20H13V23H11ZM20 13V11H23V13H20ZM1 13V11H4V13H1ZM18.7 6.7 17.3 5.3 19.05 3.5 20.5 4.95 18.7 6.7ZM4.95 20.5 3.5 19.05 5.3 17.3 6.7 18.7 4.95 20.5ZM19.05 20.5 17.3 18.7 18.7 17.3 20.5 19.05 19.05 20.5ZM5.3 6.7 3.5 4.95 4.95 3.5 6.7 5.3 5.3 6.7ZM12 18Q9.5 18 7.75 16.25T6 12Q6 9.5 7.75 7.75T12 6Q14.5 6 16.25 7.75T18 12Q18 14.5 16.25 16.25T12 18ZM12 16Q13.675 16 14.8375 14.8375T16 12Q16 10.325 14.8375 9.1625T12 8Q10.325 8 9.1625 9.1625T8 12Q8 13.675 9.1625 14.8375T12 16ZM12 12Z"), WB_SUNNY_FILL("M11 4V1h2V4H11Zm0 19V20h2v3H11Zm9-10V11h3v2H20ZM1 13V11H4v2H1ZM18.7 6.7 17.3 5.3l1.75-1.8L20.5 4.95 18.7 6.7ZM4.95 20.5 3.5 19.05 5.3 17.3l1.4 1.4-1.75 1.8Zm14.1 0-1.75-1.8 1.4-1.4 1.8 1.75-1.45 1.45ZM5.3 6.7 3.5 4.95 4.95 3.5 6.7 5.3 5.3 6.7ZM12 18q-2.5 0-4.25-1.75T6 12 7.75 7.75 12 6t4.25 1.75T18 12t-1.75 4.25T12 18Z"), + WV_CHUNK("M16.213 11.093h-6.827V4.267H1.707v14.507h14.507V11.093z m-7.68-5.973v5.973H2.56V5.12h5.973z m-5.973 6.827h5.973v5.973H2.56v-5.973z m12.8 5.973h-5.973v-5.973h5.973v5.973z m3.413-16.213h-7.68v7.68h7.68V1.707z m-0.853 6.827h-5.973V2.56h5.973v5.973z m-2.56-2.56h-0.853V5.12h0.853v0.853z") ; public static final double DEFAULT_SIZE = 24; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 23778b9cc1..41a15a1fda 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -375,6 +375,7 @@ public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupH if (ChunkBaseApp.isSupported(world)) { popupMenu.getContent().addAll( new MenuSeparator(), + new IconedMenuItem(SVG.WV_CHUNK, i18n("world.view"), () -> Controllers.navigate(new WorldViewPage(world, page.getWidth(), page.getHeight())), popup), new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup), new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup), new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index e46d7c6b86..37efc3308f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -240,6 +240,7 @@ private AdvancedListBox getToolBar() { JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu); chunkBasePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.WV_CHUNK, i18n("world.view"), () -> Controllers.navigate(new WorldViewPage(getSkinnable().world, getSkinnable().getWidth(), getSkinnable().getHeight())), chunkBasePopup), new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(getSkinnable().world), chunkBasePopup), new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(getSkinnable().world), chunkBasePopup), new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(getSkinnable().world), chunkBasePopup) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldViewPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldViewPage.java new file mode 100644 index 0000000000..b32ebccf1f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldViewPage.java @@ -0,0 +1,623 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Label; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.task.AsyncTaskExecutor; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.EOFException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.ui.versions.WorldViewPage.WorldViewer.UNKNOWN_CHUNK_COLOR; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * World view page that displays a preview of Minecraft world chunks. + * @author Xiaotian + */ +public class WorldViewPage extends DecoratorAnimatedPage implements DecoratorPage { + + // Current state of the page + private final ObjectProperty state; + // The world viewer component + private final WorldViewer viewer; + + /** + * Creates a new world view page for the specified world. + * @param world The world to view + */ + public WorldViewPage(@NotNull World world, double width, double height) { + // Initialize page state with world name as title + this.state = new SimpleObjectProperty<>(new State(i18n("world.view.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); + // Create viewer using half of available CPU cores (minimum 1) + this.viewer = new WorldViewer(world, this.getWidth(), this.getHeight(), Math.max((Runtime.getRuntime().availableProcessors() / 2), 1)); + + this.setWidth(width); + this.setHeight(height); + this.viewer.setWidth(width); + this.viewer.setHeight(height); + + LOG.debug("%f, %f".formatted(this.getWidth(), this.getHeight())); + this.viewer.render(); + // Stop rendering when page is closed + this.sceneProperty().addListener((obs, oldScene, newScene) -> { + if (oldScene != null && newScene == null) { + viewer.stopRender(); + } + }); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state; + } + + @Override + protected @NotNull Skin createDefaultSkin() { + return new Skin(this); + } + + /** + * Canvas component that displays and interacts with the world chunks. + */ + public static class WorldViewer extends Canvas { + // Color patterns for unloaded chunks (purple/black alternating) + public static final WVColor[] UNKNOWN_CHUNK_COLORSET = { + WVColor.rgb(128, 0, 128), // Purple + WVColor.rgb(0, 0, 0) // Black + }; + + public static final WVColor UNKNOWN_CHUNK_COLOR = WVColor.rgb(-1, 0, 0); + public static final WVColor UNLOADED_CHUNK_COLOR = WVColor.rgb(-1, -1, 0); + public static final WVColor UNGENERATED_CHUNK_COLOR = WVColor.rgb(-1, -1, -1); + + private double dragStartX, dragStartY; // Drag start coordinates + private int centerChunkX = 0, centerChunkZ = 0; // Center chunk coordinates + private int mouseChunkX = 0, mouseChunkZ = 0; // Mouse position in chunk coordinates + private final Label coordinateLabel = new Label("chunk(0,0)"); // Coordinate display label + + World.WorldParser worldParser; // World data parser + final Set tasks = new HashSet<>(); // Async chunk processing tasks + final RenderTask renderTask = new RenderTask(); // Main render loop task + final ConcurrentHashMap chunkColorMap = new ConcurrentHashMap<>(); // Chunk color cache + + /** + * Creates a new world viewer. + * @param world The world to display + * @param width Initial width + * @param height Initial height + * @param asyncTaskCount Number of async tasks for chunk processing + */ + public WorldViewer(@NotNull World world, double width, double height, int asyncTaskCount) { + super(width, height); + LOG.info("Initializing world view: %s [async: %d]".formatted(world.getWorldName(), asyncTaskCount)); + this.worldParser = new World.WorldParser(world); + if (asyncTaskCount <= 0) { + throw new IllegalArgumentException("Thread count must be greater than 0"); + } + // Create async tasks for chunk processing + for (int i = 0; i < asyncTaskCount; i++) { + var task = new CacheChunkColorTask(); + tasks.add(task); + } + + // Configure coordinate label style + coordinateLabel.setStyle("-fx-font-size: 14px; -fx-text-fill: white; -fx-background-color: rgba(0,0,0,0.7);"); + coordinateLabel.setPadding(new Insets(2, 5, 2, 5)); + + Platform.runLater(() -> setupMouseEvents(1.3 * (600 / getWidth()))); // 这样可以根据画布大小自动调整鼠标拖动的灵敏度,保持在不同分辨率下都有良好的体验 + } + + // Set up mouse interaction handlers + private void setupMouseEvents(double sensitivity) { + // Mouse press handler for dragging + setOnMousePressed(event -> { + dragStartX = event.getSceneX(); + dragStartY = event.getSceneY(); + }); + + // Mouse drag handler for panning + setOnMouseDragged(event -> { + double deltaX = (event.getSceneX() - dragStartX) * sensitivity; + double deltaY = (event.getSceneY() - dragStartY) * sensitivity; + + double chunkSize = getChunkSize(); + centerChunkX -= (int)(deltaX / chunkSize); + centerChunkZ -= (int)(deltaY / chunkSize); + + dragStartX = event.getSceneX(); + dragStartY = event.getSceneY(); + + requestChunksAroundCenter(); + }); + + // Mouse move handler for coordinate display + setOnMouseMoved(event -> { + double chunkSize = getChunkSize(); + mouseChunkX = centerChunkX + (int)((event.getX() - getWidth() / 2) / chunkSize); + mouseChunkZ = centerChunkZ + (int)((event.getY() - getHeight() / 2) / chunkSize); + coordinateLabel.setText(String.format("chunk(%d,%d)", mouseChunkX, mouseChunkZ)); + renderMainLoop(); + }); + } + + // Calculate chunk size in pixels based on canvas dimensions + private double getChunkSize() { + return Math.min(getWidth(), getHeight()) / 20.0; + } + + // Request loading of chunks around current center + private void requestChunksAroundCenter() { + int visibleRadius = (int) (Math.max(getWidth(), getHeight()) / getChunkSize() / 2) + 1; + List chunksToLoad = new ArrayList<>(); + + // Generate chunk coordinates in visible area + for (int x = centerChunkX - visibleRadius; x <= centerChunkX + visibleRadius; x++) { + for (int z = centerChunkZ - visibleRadius; z <= centerChunkZ + visibleRadius; z++) { + chunksToLoad.add(new World.WorldParser.Chunk(x, z, worldParser.overworld)); + } + } + + // Send chunk load requests to async tasks + CacheChunkColorTask.sendRequestAll(tasks, chunksToLoad.toArray(new World.WorldParser.Chunk[0])); + } + + /** + * Starts rendering the world view. + */ + public void render() { + // Ensure coordinate label is properly parented + if (getParent() == null) { + StackPane root = new StackPane(this); + StackPane.setAlignment(coordinateLabel, Pos.BOTTOM_RIGHT); + root.getChildren().add(coordinateLabel); + } else if (getParent() instanceof StackPane root) { + if (!root.getChildren().contains(coordinateLabel)) { + StackPane.setAlignment(coordinateLabel, Pos.BOTTOM_RIGHT); + root.getChildren().add(coordinateLabel); + } + } else { + StackPane newRoot = new StackPane(getParent(), this); + StackPane.setAlignment(coordinateLabel, Pos.BOTTOM_RIGHT); + newRoot.getChildren().add(coordinateLabel); + getParent().getChildrenUnmodifiable().stream() + .filter(node -> node != this) + .forEach(newRoot.getChildren()::add); + ((Pane)getParent()).getChildren().setAll(newRoot); + } + + (new AsyncTaskExecutor(renderTask)).start(); + + LOG.info("Start rendering world view(%fx%f): %s".formatted(this.getHeight(), this.getHeight(), worldParser.toString())); + CacheChunkColorTask.executeAll(tasks); + requestChunksAroundCenter(); + Platform.runLater(this::renderMainLoop); + } + + /** + * Stops rendering and cleans up resources. + */ + public void stopRender() { + tasks.forEach(CacheChunkColorTask::stop); + renderTask.stop(); + chunkColorMap.clear(); + LOG.info("Stopped rendering world view: %s".formatted(worldParser.toString())); + } + + public void renderMainLoop() { + GraphicsContext gc = getGraphicsContext2D(); + gc.clearRect(0, 0, getWidth(), getHeight()); + + double chunkSize = getChunkSize(); + int visibleRadius = (int) (Math.max(getWidth(), getHeight()) / chunkSize / 2) + 1; + + // Render all visible chunks + for (int x = centerChunkX - visibleRadius; x <= centerChunkX + visibleRadius; x++) { + for (int z = centerChunkZ - visibleRadius; z <= centerChunkZ + visibleRadius; z++) { + World.WorldParser.Chunk chunk = new World.WorldParser.Chunk(x, z, worldParser.overworld); + double screenX = getWidth() / 2 + (x - centerChunkX) * chunkSize; + double screenY = getHeight() / 2 + (z - centerChunkZ) * chunkSize; + + if (chunkColorMap.containsKey(chunk)) { + // Use cached color if available + WVColor c = chunkColorMap.get(chunk); + if (c == UNGENERATED_CHUNK_COLOR) { + drawUngeneratedChunkPattern(gc, screenX, screenY, chunkSize); + continue; + } else if (c == UNLOADED_CHUNK_COLOR) { + drawUnloadedChunkPattern(gc, screenX, screenY, chunkSize); + continue; + } else if (c == UNKNOWN_CHUNK_COLOR) { + drawUnknownChunkPattern(gc, screenX, screenY, chunkSize); + continue; + } else { + gc.setFill(c.get()); + } + } else { + // Use missing chunk pattern and request loading + drawUnloadedChunkPattern(gc, screenX, screenY, chunkSize); + CacheChunkColorTask.sendRequestAll(tasks, new World.WorldParser.Chunk[]{chunk}); + gc.setStroke(Color.BLACK); + gc.setLineWidth(0.5); + gc.strokeRect(screenX, screenY, chunkSize, chunkSize); + continue; + } + + // Draw chunk rectangle + gc.fillRect(screenX, screenY, chunkSize, chunkSize); + gc.setStroke(Color.BLACK); + gc.setLineWidth(0.5); + gc.strokeRect(screenX, screenY, chunkSize, chunkSize); + } + } + } + + private void drawUngeneratedChunkPattern(GraphicsContext gc, double screenX, double screenY, double chunkSize) { + gc.setStroke(Color.DARKRED); + gc.setLineWidth(1); + gc.strokeLine(screenX, screenY, screenX + chunkSize, screenY + chunkSize); + gc.strokeLine(screenX + chunkSize, screenY, screenX, screenY + chunkSize); + } + + private void drawUnknownChunkPattern(@NotNull GraphicsContext gc, double x, double y, double size) { + gc.setFill(UNKNOWN_CHUNK_COLORSET[0].get()); // Purple + gc.fillRect(x, y, size / 2, size / 2); + gc.fillRect(x + size / 2, y + size / 2, size / 2, size / 2); + + gc.setFill(UNKNOWN_CHUNK_COLORSET[1].get()); // Black + gc.fillRect(x + size / 2, y, size / 2, size / 2); + gc.fillRect(x, y + size / 2, size / 2, size / 2); + } + + private void drawUnloadedChunkPattern(@NotNull GraphicsContext gc, double x, double y, double size) { + gc.setFill(Color.LIGHTGRAY); + gc.fillRect(x, y, size, size); + gc.setStroke(Color.DARKGRAY); + gc.setLineWidth(0.5); + gc.strokeRect(x, y, size, size); + gc.setStroke(Color.GRAY); + gc.setLineWidth(1); + gc.strokeLine(x, y, x + size, y + size); + gc.strokeLine(x + size, y, x, y + size); + } + + /** + * Async task that processes chunk data to determine their colors. + */ + public class CacheChunkColorTask extends Task { + + Thread thread = null; + private final ConcurrentLinkedQueue pendingChunks = new ConcurrentLinkedQueue<>(); + + public CacheChunkColorTask() { + super(); + setSignificance(TaskSignificance.MINOR); + } + + @Override + public void execute() { + thread = Thread.currentThread(); + while (!isCancelled()) { + World.WorldParser.Chunk chunk = pendingChunks.poll(); + if (chunk == null) { + try { + Thread.sleep(20 / tasks.size()); // Avoid busy waiting + } catch (InterruptedException e) { + break; + } + continue; + } + + // Process chunk if not already cached + if (!chunkColorMap.containsKey(chunk)) { + WVColor[] chunkColors = new WVColor[256]; + // Sample block colors at top layer (y=255) + try { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int y = worldParser.getTheHighestNonAirBlock(chunk, x, z); + chunkColors[x * 16 + z] = getColor(worldParser.parseBlockFromChunkData(chunk, x, y != Integer.MIN_VALUE ? y : 64, z)); + } + } + // Determine dominant color for the chunk + chunkColorMap.put(chunk, evaluateColor(chunkColors)); + } catch (RuntimeException e) { + if (e.getCause() instanceof EOFException) { + LOG.warning("Chunk data not fully generated yet: %s".formatted(chunk)); + chunkColorMap.put(chunk, UNGENERATED_CHUNK_COLOR); + } else if (e.getCause() instanceof RuntimeException runtimeException) { // ignore known exceptions related to missing or incomplete chunk data + if (! runtimeException.getMessage().equals("Broken file head.") + && ! runtimeException.getMessage().equals("Region file does not exists.")) { + LOG.warning("An unexpected exception occurred while parsing chunk data", e); + } + chunkColorMap.put(chunk, UNGENERATED_CHUNK_COLOR); + } + } + } + } + } + + /** + * Adds a chunk to the processing queue. + * @param chunk The chunk to process + */ + public void sendRequest(World.WorldParser.Chunk chunk) { + pendingChunks.offer(chunk); + } + + public void stop() { + thread.interrupt(); + } + + /** + * Starts all chunk processing tasks. + * @param tasks The tasks to start + */ + static void executeAll(@NotNull Set tasks) { + tasks.forEach(task -> (new AsyncTaskExecutor(task)).start()); + } + + /** + * Distributes chunk processing requests across all tasks. + * @param tasks Available tasks + * @param chunks Chunks to process + */ + static void sendRequestAll(@NotNull Set tasks, World.WorldParser.Chunk @NotNull [] chunks) { + int taskCount = tasks.size(); + for (int i = 0; i < chunks.length; i++) { + CacheChunkColorTask task = tasks.stream().skip(i % taskCount).findFirst().orElseThrow(); + task.sendRequest(chunks[i]); + } + } + } + + public class RenderTask extends Task { + Thread thread = null; + + @Override + public void execute() { + thread = Thread.currentThread(); + while (! isCancelled()) { + Platform.runLater(WorldViewer.this::renderMainLoop); + try { + Thread.sleep(100 / tasks.size()); // Adjust render frequency as needed + } catch (InterruptedException e) { + break; + } + } + } + + public void stop() { + thread.interrupt(); + } + } + + /** + * Determines the dominant color from an array of colors. + * @param chunkColors Array of colors to analyze + * @return The most frequently occurring color + */ + private @NotNull WVColor evaluateColor(WVColor @NotNull [] chunkColors) { + if (Arrays.stream(chunkColors).allMatch(WVColor::isNormalColor)) { + return WVColor.fromColor(Arrays.stream(chunkColors) + .collect(Collectors.groupingBy( + c -> Arrays.asList( + (int) (c.get().getRed() * 10), + (int) (c.get().getGreen() * 10), + (int) (c.get().getBlue() * 10) + ), Collectors.counting() + )) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(e -> Color.color( + e.getKey().get(0) / 10.0, + e.getKey().get(1) / 10.0, + e.getKey().get(2) / 10.0 + )) + .orElse(null)); + } else { + return UNKNOWN_CHUNK_COLOR; + } + } + } + + /** + * Gets the color for a specific block type. + * @param blockName The block identifier + * @return The color representing the block + */ + @Contract("_ -> new") + private static @NotNull WVColor getColor(@NotNull String blockName) { + return WVColor.fromColor(switch (blockName) { + case "minecraft:air" -> Color.rgb(0, 0, 0, 0); + case "minecraft:water" -> Color.rgb(64, 164, 223); + case "minecraft:ice" -> Color.rgb(131, 190, 223); + case "minecraft:lava" -> Color.rgb(240, 88, 17); + case "minecraft:bedrock" -> Color.rgb(54, 54, 54); + case "minecraft:grass_block" -> Color.rgb(127, 178, 56); + case "minecraft:dirt" -> Color.rgb(134, 96, 67); + case "minecraft:stone" -> Color.rgb(112, 112, 112); + case "minecraft:sand" -> Color.rgb(218, 210, 158); + case "minecraft:gravel" -> Color.rgb(136, 126, 126); + default -> BlockColorFilter.getColorByFilter(blockName); + }); + } + + public static final class WVColor { + private final Color color; + + private WVColor(int r, int g, int b, int a) { + if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 || a < 0 || a > 1) { + color = null; + } else { + color = Color.rgb(r, g, b, a); + } + } + + @Contract(value = "_, _, _ -> new", pure = true) + private static @NotNull WVColor rgb(int r, int g, int b) { + return new WVColor(r, g, b, 1); + } + + @Contract(value = "_, _, _, _ -> new", pure = true) + private static @NotNull WVColor rgb(int r, int g, int b, int a) { + return new WVColor(r, g, b, a); + } + + @Contract("_ -> new") + private static @NotNull WVColor fromColor(Color color) { + if (color == null) return UNKNOWN_CHUNK_COLOR; + return new WVColor( + (int)(color.getRed() * 255), + (int)(color.getGreen() * 255), + (int)(color.getBlue() * 255), + (int)(color.getOpacity()) + ); + } + + private Color get() { + return color; + } + + public boolean isNormalColor() { + return color != null; + } + } + + public static class Skin extends DecoratorAnimatedPageSkin { + public Skin(WorldViewPage page) { + super(page); + setCenter(page.viewer); + } + } + + public static final class BlockColorFilter { + public static final BlockColorFilter GRASS = new BlockColorFilter(new String[] {"minecraft:grass_block", "minecraft:tall_grass", "minecraft:fern"}); + public static final BlockColorFilter LEAVES = new BlockColorFilter(new String[] { + "minecraft:oak_leaves", "minecraft:spruce_leaves", "minecraft:birch_leaves", + "minecraft:jungle_leaves", "minecraft:acacia_leaves", "minecraft:dark_oak_leaves" + }); + + public static final BlockColorFilter WHITE_COLOR_BLOCKS = new BlockColorFilter(":.*white"); + public static final BlockColorFilter ORANGE_COLOR_BLOCKS = new BlockColorFilter(":.*orange"); + public static final BlockColorFilter MAGENTA_COLOR_BLOCKS = new BlockColorFilter(":.*magenta"); + public static final BlockColorFilter LIGHT_BLUE_COLOR_BLOCKS = new BlockColorFilter(":.*light_blue"); + public static final BlockColorFilter YELLOW_COLOR_BLOCKS = new BlockColorFilter(":.*yellow"); + public static final BlockColorFilter LIME_COLOR_BLOCKS = new BlockColorFilter(":.*lime"); + public static final BlockColorFilter PINK_COLOR_BLOCKS = new BlockColorFilter(":.*pink"); + public static final BlockColorFilter GRAY_COLOR_BLOCKS = new BlockColorFilter(":.*gray"); + public static final BlockColorFilter LIGHT_GRAY_COLOR_BLOCKS = new BlockColorFilter(":.*light_gray"); + public static final BlockColorFilter CYAN_COLOR_BLOCKS = new BlockColorFilter(":.*cyan"); + public static final BlockColorFilter PURPLE_COLOR_BLOCKS = new BlockColorFilter(":.*purple"); + public static final BlockColorFilter BLUE_COLOR_BLOCKS = new BlockColorFilter(":.*blue"); + public static final BlockColorFilter BROWN_COLOR_BLOCKS = new BlockColorFilter(":.*brown"); + public static final BlockColorFilter GREEN_COLOR_BLOCKS = new BlockColorFilter(":.*green"); + public static final BlockColorFilter RED_COLOR_BLOCKS = new BlockColorFilter(":.*red"); + public static final BlockColorFilter BLACK_COLOR_BLOCKS = new BlockColorFilter(":.*black"); + + final String[] blocks; + final String regex; + + public BlockColorFilter(String[] blocks) { + this.blocks = blocks; + this.regex = null; + } + + public BlockColorFilter(String regex) { + this.blocks = null; + this.regex = regex; + } + + public boolean matches(String blockName) { + if (regex == null && blocks != null) { + for (String block : blocks) { + if (block.equals(blockName)) { + return true; + } + } + } else if (regex != null) { + return blockName.matches(regex); + } + return false; + } + + private static @Nullable Color getColorByFilter(String blockName) { + if (BlockColorFilter.WHITE_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(240, 240, 240); + } else if (BlockColorFilter.ORANGE_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(216, 127, 51); + } else if (BlockColorFilter.MAGENTA_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(178, 76, 216); + } else if (BlockColorFilter.LIGHT_BLUE_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(102, 153, 216); + } else if (BlockColorFilter.YELLOW_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(229, 229, 51); + } else if (BlockColorFilter.LIME_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(127, 204, 25); + } else if (BlockColorFilter.PINK_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(242, 127, 165); + } else if (BlockColorFilter.GRAY_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(76, 76, 76); + } else if (BlockColorFilter.LIGHT_GRAY_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(153, 153, 153); + } else if (BlockColorFilter.CYAN_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(76, 127, 153); + } else if (BlockColorFilter.PURPLE_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(127, 63, 178); + } else if (BlockColorFilter.BLUE_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(51, 76, 178); + } else if (BlockColorFilter.BROWN_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(102, 76, 51); + } else if (BlockColorFilter.GREEN_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(102, 127, 51); + } else if (BlockColorFilter.RED_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(153, 51, 51); + } else if (BlockColorFilter.BLACK_COLOR_BLOCKS.matches(blockName)) { + return Color.rgb(25, 25, 25); + } else if (BlockColorFilter.GRASS.matches(blockName)) { + return Color.rgb(127, 178, 56); + } else if (BlockColorFilter.LEAVES.matches(blockName)) { + return Color.rgb(63, 179, 63); + } + return null; + } + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 2f682895b8..fb5c723f14 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1668,4 +1668,6 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s wizard.prev=< Prev wizard.failed=Failed wizard.finish=Finish -wizard.next=Next > \ No newline at end of file +wizard.next=Next > +world.view=World View +world.view.title=World View: %s \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 6832652a9e..a8813d2c88 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1555,3 +1555,5 @@ wizard.prev=< السابق wizard.failed=فشل wizard.finish=إنهاء wizard.next=التالي > +world.view=World View +world.view.title=World View: %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index f2df08ec44..08ffe9b23d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1482,3 +1482,5 @@ wizard.prev=< Previo wizard.failed=Falló wizard.finish=Finalizar wizard.next=Siguiente > +world.view=World View +world.view.title=World View: %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 88acdd9d25..2651aa6038 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -961,3 +961,5 @@ wizard.prev=< 前へ wizard.failed=失敗 wizard.finish=終了 wizard.next=次へ > +world.view=World View +world.view.title=World View: %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 9ee93cab82..d32630ec4d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1465,3 +1465,5 @@ wizard.prev=< Пред. wizard.failed=Не удалось wizard.finish=Завершено wizard.next=След. > +world.view=World View +world.view.title=World View: %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 8fb8f744a2..4e047b8307 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1412,3 +1412,5 @@ wizard.prev=< Попередній wizard.failed=Не вдалося wizard.finish=Завершити wizard.next=Наступний > +world.view=World View +world.view.title=World View: %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 94b50e0302..30c0f0be4d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1445,3 +1445,5 @@ wizard.prev=< 上一步 wizard.failed=失敗 wizard.finish=完成 wizard.next=下一步 > +world.view=World View +world.view.title=World View: %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index c85d174044..5497f0c04e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1450,3 +1450,5 @@ wizard.prev=< 上一步 wizard.failed=失败 wizard.finish=完成 wizard.next=下一步 > +world.view=World View +world.view.title=World View: %s diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 8e3f6fc38c..1f529aaf6a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -22,21 +22,24 @@ import javafx.scene.image.Image; import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; +import java.io.*; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; +import java.util.zip.Inflater; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -51,12 +54,13 @@ public final class World { public World(Path file) throws IOException { this.file = file; - if (Files.isDirectory(file)) + if (Files.isDirectory(file)) { loadFromDirectory(); - else if (Files.isRegularFile(file)) + } else if (Files.isRegularFile(file)) { loadFromZip(); - else + } else { throw new IOException("Path " + file + " cannot be recognized as a Minecraft world"); + } } public Path getFile() { @@ -99,9 +103,9 @@ public long getLastPlayed() { } public @Nullable GameVersionNumber getGameVersion() { - if (levelData.get("Data") instanceof CompoundTag data && - data.get("Version") instanceof CompoundTag versionTag && - versionTag.get("Name") instanceof StringTag nameTag) { + if (levelData.get("Data") instanceof CompoundTag data + && data.get("Version") instanceof CompoundTag versionTag + && versionTag.get("Name") instanceof StringTag nameTag) { return GameVersionNumber.asGameVersion(nameTag.getValue()); } return null; @@ -109,9 +113,9 @@ public long getLastPlayed() { public @Nullable Long getSeed() { CompoundTag data = levelData.get("Data"); - if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { //Valid after 1.16 + if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { return seedTag.getValue(); - } else if (data.get("RandomSeed") instanceof LongTag seedTag) { //Valid before 1.16 + } else if (data.get("RandomSeed") instanceof LongTag seedTag) { return seedTag.getValue(); } return null; @@ -119,7 +123,7 @@ public long getLastPlayed() { public boolean isLargeBiomes() { CompoundTag data = levelData.get("Data"); - if (data.get("generatorName") instanceof StringTag generatorNameTag) { //Valid before 1.16 + if (data.get("generatorName") instanceof StringTag generatorNameTag) { return "largeBiomes".equals(generatorNameTag.getValue()); } else { if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag @@ -127,9 +131,9 @@ public boolean isLargeBiomes() { && dimensionsTag.get("minecraft:overworld") instanceof CompoundTag overworldTag && overworldTag.get("generator") instanceof CompoundTag generatorTag) { if (generatorTag.get("biome_source") instanceof CompoundTag biomeSourceTag - && biomeSourceTag.get("large_biomes") instanceof ByteTag largeBiomesTag) { //Valid between 1.16 and 1.16.2 + && biomeSourceTag.get("large_biomes") instanceof ByteTag largeBiomesTag) { return largeBiomesTag.getValue() == (byte) 1; - } else if (generatorTag.get("settings") instanceof StringTag settingsTag) { //Valid after 1.16.2 + } else if (generatorTag.get("settings") instanceof StringTag settingsTag) { return "minecraft:large_biomes".equals(settingsTag.getValue()); } } @@ -160,7 +164,7 @@ public static boolean supportQuickPlay(GameVersionNumber gameVersionNumber) { private void loadFromDirectory() throws IOException { fileName = FileUtils.getName(file); Path levelDat = file.resolve("level.dat"); - if (!Files.exists(levelDat)) { // version 20w14infinite + if (!Files.exists(levelDat)) { levelDat = file.resolve("special_level.dat"); } if (!Files.exists(levelDat)) { @@ -173,8 +177,9 @@ private void loadFromDirectory() throws IOException { if (Files.isRegularFile(iconFile)) { try (InputStream inputStream = Files.newInputStream(iconFile)) { icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) + if (icon.isError()) { throw icon.getException(); + } } catch (Exception e) { LOG.warning("Failed to load world icon", e); } @@ -183,7 +188,7 @@ private void loadFromDirectory() throws IOException { private void loadFromZipImpl(Path root) throws IOException { Path levelDat = root.resolve("level.dat"); - if (!Files.exists(levelDat)) { //version 20w14infinite + if (!Files.exists(levelDat)) { levelDat = root.resolve("special_level.dat"); } if (!Files.exists(levelDat)) { @@ -195,8 +200,9 @@ private void loadFromZipImpl(Path root) throws IOException { if (Files.isRegularFile(iconFile)) { try (InputStream inputStream = Files.newInputStream(iconFile)) { icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) + if (icon.isError()) { throw icon.getException(); + } } catch (Exception e) { LOG.warning("Failed to load world icon", e); } @@ -211,7 +217,6 @@ private void loadFromZip() throws IOException { loadFromZipImpl(fs.getPath("/")); return; } - try (Stream stream = Files.list(fs.getPath("/"))) { Path root = stream.filter(Files::isDirectory).findAny() .orElseThrow(() -> new IOException("Not a valid world zip file")); @@ -224,14 +229,15 @@ private void loadFromZip() throws IOException { private void loadAndCheckLevelDat(Path levelDat) throws IOException { this.levelData = parseLevelDat(levelDat); CompoundTag data = levelData.get("Data"); - if (data == null) + if (data == null) { throw new IOException("level.dat missing Data"); - - if (!(data.get("LevelName") instanceof StringTag)) + } + if (!(data.get("LevelName") instanceof StringTag)) { throw new IOException("level.dat missing LevelName"); - - if (!(data.get("LastPlayed") instanceof LongTag)) + } + if (!(data.get("LastPlayed") instanceof LongTag)) { throw new IOException("level.dat missing LastPlayed"); + } } public void reloadLevelDat() throws IOException { @@ -243,8 +249,9 @@ public void reloadLevelDat() throws IOException { // The rename method is used to rename temporary world object during installation and copying, // so there is no need to modify the `file` field. public void rename(String newName) throws IOException { - if (!Files.isDirectory(file)) + if (!Files.isDirectory(file)) { throw new IOException("Not a valid world directory"); + } // Change the name recorded in level.dat CompoundTag data = levelData.get("Data"); @@ -295,8 +302,9 @@ public void install(Path savesDir, String name) throws IOException { } public void export(Path zip, String worldName) throws IOException { - if (!Files.isDirectory(file)) + if (!Files.isDirectory(file)) { throw new IOException(); + } try (Zipper zipper = new Zipper(zip)) { zipper.putDirectory(file, worldName); @@ -346,8 +354,9 @@ public FileChannel lock() throws WorldLockedException { } public void writeLevelDat(CompoundTag nbt) throws IOException { - if (!Files.isDirectory(file)) + if (!Files.isDirectory(file)) { throw new IOException("Not a valid world directory"); + } FileUtils.saveSafely(getLevelDatFile(), os -> { try (OutputStream gos = new GZIPOutputStream(os)) { @@ -359,10 +368,11 @@ public void writeLevelDat(CompoundTag nbt) throws IOException { private static CompoundTag parseLevelDat(Path path) throws IOException { try (InputStream is = new GZIPInputStream(Files.newInputStream(path))) { Tag nbt = NBTIO.readTag(is); - if (nbt instanceof CompoundTag compoundTag) + if (nbt instanceof CompoundTag compoundTag) { return compoundTag; - else + } else { throw new IOException("level.dat malformed"); + } } } @@ -396,4 +406,463 @@ public static List getWorlds(Path savesDir) { } return List.of(); } + + @Override + public String toString() { + return "World" + (isLocked() ? " (Locked) " : "") + + "{name='" + getWorldName() + "'" + + ",seed=" + getSeed() + + "}"; + } + + /** + * @author Xiaotian + * @see The Region file format + */ + public static class WorldParser { + + private static final int SECTOR_SIZE = 4096; + private static final int HEADER_SIZE = 8192; + + private static final int COMPRESSION_ZLIB = 2; + private static final int COMPRESSION_LZ4 = 4; + + public final WorldPath overworld; + public final WorldPath the_nether; + public final WorldPath the_end; + + private final ConcurrentHashMap chunkCache = new ConcurrentHashMap<>(); + public final @NotNull World world; + + public WorldParser(@NotNull World world) { + LOG.info("Parsing world(%s)[%s]".formatted(world.getGameVersion(), world.getWorldName())); + + this.world = world; + + if (Objects.requireNonNull(world.getGameVersion()).isAtLeast("26.1", "26.1-snapshot-6")) { + Path vanillaWorldPathRoot = world.getFile().resolve("dimensions/minecraft"); + + overworld = new WorldPath( + Files.exists(vanillaWorldPathRoot.resolve("overworld")) ? vanillaWorldPathRoot.resolve("overworld") : null, + "overworld" + ); + the_nether = new WorldPath( + Files.exists(vanillaWorldPathRoot.resolve("the_nether")) ? vanillaWorldPathRoot.resolve("the_nether") : null, + "the_nether" + ); + the_end = new WorldPath( + Files.exists(vanillaWorldPathRoot.resolve("the_end")) ? vanillaWorldPathRoot.resolve("the_end") : null, + "the_end" + ); + } else { + overworld = new WorldPath(world.getFile(), "overworld"); + the_nether = new WorldPath( + Files.exists(world.getFile().resolve("DIM-1")) ? world.getFile().resolve("DIM-1") : null, + "the_nether" + ); + the_end = new WorldPath( + Files.exists(world.getFile().resolve("DIM1")) ? world.getFile().resolve("DIM1") : null, + "the_end" + ); + } + } + + public byte[] parseChunk(int chunkX, int chunkZ, WorldPath worldPath) throws RuntimeException { + try { + int regionX = chunkX >> 5; + int regionZ = chunkZ >> 5; + int localX = chunkX & 0x1F; + int localZ = chunkZ & 0x1F; + + String regionFile = String.format("r.%d.%d.mca", regionX, regionZ); + Path regionPath = worldPath.get().resolve(Paths.get("region", regionFile)); + + if (!Files.exists(regionPath)) { + throw new RuntimeException("Region file does not exists."); + } + + byte[] header = Files.readAllBytes(regionPath); + if (header.length < HEADER_SIZE) { + throw new RuntimeException("Broken file head."); + } + + int blockIndex = localX + 32 * localZ; + int headerOffset = blockIndex * 4; + + int sectorOffset = ((header[headerOffset] & 0xFF) << 16) + | ((header[headerOffset + 1] & 0xFF) << 8) + | (header[headerOffset + 2] & 0xFF); + + int sectorCount = header[headerOffset + 3] & 0xFF; + + if (sectorOffset == 0 || sectorCount == 0) { + return new byte[] {}; + } + + int dataOffset = sectorOffset * SECTOR_SIZE; + int compressionType = header[dataOffset + 4] & 0xFF; + if (dataOffset + 5 > header.length) { + // 数据可能在额外文件中 + return readFromExternalFile(chunkX, chunkZ, compressionType, worldPath); + } + + int dataLength = ((header[dataOffset] & 0xFF) << 24) + | ((header[dataOffset + 1] & 0xFF) << 16) + | ((header[dataOffset + 2] & 0xFF) << 8) + | (header[dataOffset + 3] & 0xFF); + + if ((compressionType & 0x80) != 0) { + return readFromExternalFile(chunkX, chunkZ, compressionType, worldPath); + } + + byte[] compressedData = Files.readAllBytes(regionPath); + if (dataOffset + 5 + dataLength > compressedData.length) { + throw new RuntimeException("Illegal chunk data"); + } + + byte[] chunkData = decompressData( + Arrays.copyOfRange(compressedData, dataOffset + 5, dataOffset + 5 + dataLength), + compressionType + ); + + Chunk chunk = new Chunk(chunkX, chunkZ, worldPath); + chunkCache.put(chunk, chunkData); + + return chunkData; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public String parseBlockFromChunkData(int chunkX, int chunkZ, int x, int y, int z, WorldPath worldPath) { + Chunk chunk = new Chunk(chunkX, chunkZ, worldPath); + byte[] data = null; + if (!chunkCache.containsKey(chunk)) { + data = parseChunk(chunkX, chunkZ, worldPath); + } + return parseBlockFromChunkData(data == null ? chunkCache.get(chunk) : data, x, y, z); + } + + /* + * @throws EOFException if chunk data is not found or not generated by MC + */ + public String parseBlockFromChunkData(@NotNull Chunk chunk, int x, int y, int z) { + byte[] data = null; + if (!chunkCache.containsKey(chunk)) { + data = parseChunk(chunk.x, chunk.z, chunk.world); + } + return parseBlockFromChunkData(data == null ? chunkCache.get(chunk) : data, x, y, z); + } + + public String parseBlockFromChunkData(byte[] chunkData, int x, int y, int z) { + try { + return parseBlockFromNBT(NBTIO.readTag(new ByteArrayInputStream(chunkData)), x, y, z); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private byte[] readFromExternalFile(int chunkX, int chunkZ, int compressionType, WorldPath worldPath) { + try { + String externalFile = String.format("-%d.%d.mcc", chunkX, chunkZ); + Path externalPath = worldPath.get().resolve(Paths.get("region", externalFile)); + + if (!Files.exists(externalPath)) { + throw new RuntimeException("External region file not found."); + } + + byte[] externalData = Files.readAllBytes(externalPath); + return decompressData(removeHeader(externalData), compressionType); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private byte[] decompressData(byte[] data, int compressionType) throws Exception { + return switch (compressionType) { + case COMPRESSION_ZLIB -> decompressZlib(data); + case 3 -> data; + default -> throw new UnsupportedOperationException("Unsupported compression: " + compressionType); + }; + } + + private byte @NotNull [] decompressZlib(byte[] data) throws Exception { + Inflater inflater = new Inflater(); + inflater.setInput(data); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length * 2); + byte[] buffer = new byte[1024]; + + while (!inflater.finished()) { + int count = inflater.inflate(buffer); + outputStream.write(buffer, 0, count); + } + + outputStream.close(); + return outputStream.toByteArray(); + } + + private byte @NotNull [] removeHeader(byte @NotNull [] data) { + return Arrays.copyOfRange(data, 4, data.length); + } + + private @Nullable String parseBlockFromNBT(@NotNull Tag chunkData, int x, int y, int z) { + if (!(chunkData instanceof CompoundTag chunk)) { + return null; + } + + x &= 15; + z &= 15; + + if (!chunk.contains("sections")) { + return null; + } + + ListTag sections = chunk.get("sections"); + if (sections.size() <= 0) { + return null; + } + + int sectionY = y >> 4; + int localY = y & 15; + + for (int i = 0; i < sections.size(); i++) { + Tag sectionTag = sections.get(i); + if (!(sectionTag instanceof CompoundTag section)) { + continue; + } + + if (!section.contains("Y") || !(section.get("Y") instanceof ByteTag)) { + continue; + } + + byte sectionYValue = ((ByteTag) section.get("Y")).getValue(); + if (sectionYValue != sectionY) { + continue; + } + + if (!section.contains("block_states") || !(section.get("block_states") instanceof CompoundTag blockStates)) { + return "minecraft:air"; + } + + if (!blockStates.contains("palette") || !(blockStates.get("palette") instanceof ListTag palette)) { + return "minecraft:air"; + } + + if (palette.size() <= 0) { + return "minecraft:air"; + } + + if (!blockStates.contains("data") || !(blockStates.get("data") instanceof LongArrayTag dataArray)) { + Tag firstBlock = palette.get(0); + if (firstBlock instanceof CompoundTag blockEntry) { + if (blockEntry.contains("Name") && blockEntry.get("Name") instanceof StringTag nameTag) { + return nameTag.getValue(); + } + } + return "minecraft:air"; + } + + long[] data = dataArray.getValue(); + int index3D = localY * 256 + z * 16 + x; + int longIndex = index3D >> 6; + int bitOffset = (index3D & 63); + + if (longIndex >= data.length) { + return "minecraft:air"; + } + + long value = data[longIndex]; + int paletteIndex = (int) ((value >>> bitOffset) & 0x3F); + + if (paletteIndex >= palette.size()) { + paletteIndex = 0; + } + + Tag blockTag = palette.get(paletteIndex); + if (blockTag instanceof CompoundTag blockEntry) { + if (blockEntry.contains("Name") && blockEntry.get("Name") instanceof StringTag nameTag) { + return nameTag.getValue(); + } + } + + return "minecraft:air"; + } + return "minecraft:air"; + } + + /** + * 获取指定区块内某个位置的最高非空气方块 + * @param chunk 区块对象 + * @param x 区块内X坐标 (0-15) + * @param z 区块内Z坐标 (0-15) + * @return 最高非空气方块的Y坐标,如果没有找到则返回Integer.MIN_VALUE + */ + public int getTheHighestNonAirBlock(Chunk chunk, int x, int z) { + x &= 15; + z &= 15; + + // 尝试从缓存获取区块数据 + byte[] chunkData = chunkCache.get(chunk); + if (chunkData == null) { + try { + chunkData = parseChunk(chunk.x, chunk.z, chunk.world); + } catch (RuntimeException e) { + return Integer.MIN_VALUE; + } + } + + try { + // 一次性解析整个区块的NBT数据 + CompoundTag chunkTag = (CompoundTag) NBTIO.readTag(new ByteArrayInputStream(chunkData)); + + if (!chunkTag.contains("sections")) { + return Integer.MIN_VALUE; + } + + ListTag sections = chunkTag.get("sections"); + + // 从最高section开始向下搜索(优化搜索顺序) + for (int sectionIndex = sections.size() - 1; sectionIndex >= 0; sectionIndex--) { + Tag sectionTag = sections.get(sectionIndex); + if (!(sectionTag instanceof CompoundTag section)) { + continue; + } + + if (!section.contains("Y") || !(section.get("Y") instanceof ByteTag sectionYTag)) { + continue; + } + + int sectionBaseY = sectionYTag.getValue() * 16; + + // 处理block_states + if (!section.contains("block_states") || !(section.get("block_states") instanceof CompoundTag blockStates)) { + continue; + } + + ListTag palette = null; + LongArrayTag dataArray = null; + + if (blockStates.contains("palette") && blockStates.get("palette") instanceof ListTag paletteTag) { + palette = paletteTag; + } + if (blockStates.contains("data") && blockStates.get("data") instanceof LongArrayTag dataArrayTag) { + dataArray = dataArrayTag; + } + + // 在当前section内从最高处向最低处搜索 + for (int localY = 15; localY >= 0; localY--) { + String blockName = getBlockNameAtPosition(palette, dataArray, x, localY, z); + + if (blockName != null && !isAirBlock(blockName)) { + return sectionBaseY + localY; + } + } + } + + return Integer.MIN_VALUE; + + } catch (IOException e) { + return Integer.MIN_VALUE; + } + } + + /** + * 获取指定位置的方块名称 + */ + private String getBlockNameAtPosition(ListTag palette, LongArrayTag dataArray, int x, int localY, int z) { + if (palette == null || palette.size() == 0) { + return "minecraft:air"; + } + + // 如果只有调色板没有数据数组,返回调色板第一个方块 + if (dataArray == null) { + Tag firstBlock = palette.get(0); + if (firstBlock instanceof CompoundTag blockEntry && + blockEntry.contains("Name") && blockEntry.get("Name") instanceof StringTag nameTag) { + return nameTag.getValue(); + } + return "minecraft:air"; + } + + long[] data = dataArray.getValue(); + int index3D = localY * 256 + z * 16 + x; + int longIndex = index3D >> 6; + int bitOffset = index3D & 63; + + if (longIndex >= data.length) { + return "minecraft:air"; + } + + long value = data[longIndex]; + int bitsPerBlock = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1)); + int paletteIndex = (int)((value >>> bitOffset) & ((1L << bitsPerBlock) - 1)); + + if (paletteIndex >= palette.size()) { + paletteIndex = 0; + } + + Tag blockTag = palette.get(paletteIndex); + if (blockTag instanceof CompoundTag blockEntry) { + if (blockEntry.contains("Name") && blockEntry.get("Name") instanceof StringTag nameTag) { + return nameTag.getValue(); + } + } + + return "minecraft:air"; + } + + private boolean isAirBlock(String blockName) { + return "minecraft:air".equals(blockName) || + blockName.startsWith("minecraft:cave_air") || + blockName.startsWith("minecraft:void_air") || + blockName.isEmpty(); + } + + public WorldPath getOverworld() { + return overworld; + } + + public WorldPath getThe_nether() { + return the_nether; + } + + public WorldPath getThe_end() { + return the_end; + } + + @Override + public String toString() { + return world.toString(); + } + + public record Chunk(int x, int z, WorldPath world) { + + @Override + public boolean equals(Object o) { + if (o instanceof Chunk chunk) { + return chunk.x == x && chunk.z == z && chunk.world.equals(world); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(x, z, world); + } + } + + public record WorldPath(Path worldPath, String worldType) { + + public Path get() { + return worldPath; + } + + @Override + public @NotNull String toString() { + return String.format("WorldPath<%s>{%s}", worldType, worldPath); + } + } + } }