diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 0928368e68..30c06ee7fa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.versions; import com.github.steveice10.opennbt.tag.builtin.*; +import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXTextField; import javafx.beans.property.SimpleBooleanProperty; @@ -28,27 +29,36 @@ import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.effect.BoxBlur; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import org.glavo.png.javafx.PNGJavaFXUtils; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; +import org.jetbrains.annotations.PropertyKey; +import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.text.DecimalFormat; import java.time.Instant; import java.util.Arrays; import java.util.Locale; +import java.util.concurrent.Callable; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo @@ -58,6 +68,8 @@ public final class WorldInfoPage extends SpinnerPane { private final World world; private CompoundTag levelDat; + ImageView iconImageView = new ImageView(); + public WorldInfoPage(WorldManagePage worldManagePage) { this.worldManagePage = worldManagePage; this.world = worldManagePage.getWorld(); @@ -80,7 +92,7 @@ private CompoundTag loadWorldInfo() throws IOException { if (!Files.isDirectory(world.getFile())) throw new IOException("Not a valid world directory"); - return world.readLevelDat(); + return world.getLevelData(); } private void updateControls() { @@ -103,98 +115,132 @@ private void updateControls() { { BorderPane worldNamePane = new BorderPane(); { - Label label = new Label(i18n("world.name")); - worldNamePane.setLeft(label); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - - Label worldNameLabel = new Label(); - FXUtils.copyOnDoubleClick(worldNameLabel); - worldNameLabel.setText(world.getWorldName()); - BorderPane.setAlignment(worldNameLabel, Pos.CENTER_RIGHT); - worldNamePane.setRight(worldNameLabel); + setLeftLabel(worldNamePane, "world.name"); + JFXTextField worldNameField = new JFXTextField(); + setRightTextField(worldNamePane, worldNameField, 200); + + Tag tag = dataTag.get("LevelName"); + if (tag instanceof StringTag stringTag) { + worldNameField.setText(stringTag.getValue()); + + worldNameField.textProperty().addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + try { + world.setWorldName(newValue); + } catch (Exception e) { + LOG.warning("Failed to set world name", e); + } + } + }); + } else { + worldNameField.setDisable(true); + } } BorderPane gameVersionPane = new BorderPane(); { - Label label = new Label(i18n("world.info.game_version")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - gameVersionPane.setLeft(label); - + setLeftLabel(gameVersionPane, "world.info.game_version"); Label gameVersionLabel = new Label(); - FXUtils.copyOnDoubleClick(gameVersionLabel); - if (world.getGameVersion() != null) - gameVersionLabel.setText(world.getGameVersion().toNormalizedString()); - BorderPane.setAlignment(gameVersionLabel, Pos.CENTER_RIGHT); - gameVersionPane.setRight(gameVersionLabel); + setRightTextLabel(gameVersionPane, gameVersionLabel, () -> world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString()); } - BorderPane randomSeedPane = new BorderPane(); + BorderPane iconPane = new BorderPane(); { + setLeftLabel(iconPane, "world.icon"); - HBox left = new HBox(8); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - left.setAlignment(Pos.CENTER_LEFT); - randomSeedPane.setLeft(left); + Runnable onClickAction = () -> Controllers.confirm( + i18n("world.icon.change.tip"), i18n("world.icon.change"), MessageDialogPane.MessageType.INFO, + this::changeWorldIcon, + null + ); - Label label = new Label(i18n("world.info.random_seed")); + FXUtils.limitSize(iconImageView, 32, 32); + { + iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); + } + + JFXButton editIconButton = new JFXButton(); + JFXButton resetIconButton = new JFXButton(); + { + editIconButton.setGraphic(SVG.EDIT.createIcon(20)); + editIconButton.setDisable(worldManagePage.isReadOnly()); + FXUtils.onClicked(editIconButton, onClickAction); + FXUtils.installFastTooltip(editIconButton, i18n("button.edit")); + editIconButton.getStyleClass().add("toggle-icon4"); + + resetIconButton.setGraphic(SVG.RESTORE.createIcon(20)); + resetIconButton.setDisable(worldManagePage.isReadOnly()); + FXUtils.onClicked(resetIconButton, this::clearWorldIcon); + FXUtils.installFastTooltip(resetIconButton, i18n("button.reset")); + resetIconButton.getStyleClass().add("toggle-icon4"); + } + + HBox hBox = new HBox(8); + hBox.setAlignment(Pos.CENTER_LEFT); + hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton); + + iconPane.setRight(hBox); + } + + BorderPane seedPane = new BorderPane(); + { + setLeftLabel(seedPane, "world.info.random_seed"); SimpleBooleanProperty visibility = new SimpleBooleanProperty(); StackPane visibilityButton = new StackPane(); - visibilityButton.setCursor(Cursor.HAND); - FXUtils.setLimitWidth(visibilityButton, 12); - FXUtils.setLimitHeight(visibilityButton, 12); - FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get())); + { + visibilityButton.setCursor(Cursor.HAND); + visibilityButton.setAlignment(Pos.BOTTOM_RIGHT); + FXUtils.setLimitWidth(visibilityButton, 12); + FXUtils.setLimitHeight(visibilityButton, 12); + FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get())); + } - left.getChildren().setAll(label, visibilityButton); + Label seedLabel = new Label(); + { + FXUtils.copyOnDoubleClick(seedLabel); + seedLabel.setAlignment(Pos.CENTER_RIGHT); - Label randomSeedLabel = new Label(); - FXUtils.copyOnDoubleClick(randomSeedLabel); - BorderPane.setAlignment(randomSeedLabel, Pos.CENTER_RIGHT); - randomSeedPane.setRight(randomSeedLabel); + seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : ""); - Tag tag = worldGenSettings != null ? worldGenSettings.get("seed") : dataTag.get("RandomSeed"); - if (tag instanceof LongTag) { - randomSeedLabel.setText(tag.getValue().toString()); + BoxBlur blur = new BoxBlur(); + blur.setIterations(3); + FXUtils.onChangeAndOperate(visibility, isVisibility -> { + SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF; + visibilityButton.getChildren().setAll(icon.createIcon(12)); + seedLabel.setEffect(isVisibility ? null : blur); + }); } - BoxBlur blur = new BoxBlur(); - blur.setIterations(3); - FXUtils.onChangeAndOperate(visibility, isVisibility -> { - SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF; - visibilityButton.getChildren().setAll(icon.createIcon(12)); - randomSeedLabel.setEffect(isVisibility ? null : blur); - }); + HBox right = new HBox(8); + { + BorderPane.setAlignment(right, Pos.CENTER_RIGHT); + right.getChildren().setAll(visibilityButton, seedLabel); + seedPane.setRight(right); + } } BorderPane lastPlayedPane = new BorderPane(); { - Label label = new Label(i18n("world.info.last_played")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - lastPlayedPane.setLeft(label); - + setLeftLabel(lastPlayedPane, "world.info.last_played"); Label lastPlayedLabel = new Label(); - FXUtils.copyOnDoubleClick(lastPlayedLabel); - lastPlayedLabel.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))); - BorderPane.setAlignment(lastPlayedLabel, Pos.CENTER_RIGHT); - lastPlayedPane.setRight(lastPlayedLabel); + setRightTextLabel(lastPlayedPane, lastPlayedLabel, () -> formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))); } BorderPane timePane = new BorderPane(); { - Label label = new Label(i18n("world.info.time")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - timePane.setLeft(label); + setLeftLabel(timePane, "world.info.time"); Label timeLabel = new Label(); - FXUtils.copyOnDoubleClick(timeLabel); - BorderPane.setAlignment(timeLabel, Pos.CENTER_RIGHT); - timePane.setRight(timeLabel); - - Tag tag = dataTag.get("Time"); - if (tag instanceof LongTag) { - long days = ((LongTag) tag).getValue() / 24000; - timeLabel.setText(i18n("world.info.time.format", days)); - } + setRightTextLabel(timePane, timeLabel, () -> { + Tag tag = dataTag.get("Time"); + if (tag instanceof LongTag) { + long days = ((LongTag) tag).getValue() / 24000; + return i18n("world.info.time.format", days); + } else { + return ""; + } + }); } OptionToggleButton allowCheatsButton = new OptionToggleButton(); @@ -203,21 +249,7 @@ private void updateControls() { allowCheatsButton.setDisable(worldManagePage.isReadOnly()); Tag tag = dataTag.get("allowCommands"); - if (tag instanceof ByteTag) { - ByteTag byteTag = (ByteTag) tag; - byte value = byteTag.getValue(); - if (value == 0 || value == 1) { - allowCheatsButton.setSelected(value == 1); - allowCheatsButton.selectedProperty().addListener((o, oldValue, newValue) -> { - byteTag.setValue(newValue ? (byte) 1 : (byte) 0); - saveLevelDat(); - }); - } else { - allowCheatsButton.setDisable(true); - } - } else { - allowCheatsButton.setDisable(true); - } + checkTagAndSetListener(tag, allowCheatsButton); } OptionToggleButton generateFeaturesButton = new OptionToggleButton(); @@ -226,28 +258,12 @@ private void updateControls() { generateFeaturesButton.setDisable(worldManagePage.isReadOnly()); Tag tag = worldGenSettings != null ? worldGenSettings.get("generate_features") : dataTag.get("MapFeatures"); - if (tag instanceof ByteTag) { - ByteTag byteTag = (ByteTag) tag; - byte value = byteTag.getValue(); - if (value == 0 || value == 1) { - generateFeaturesButton.setSelected(value == 1); - generateFeaturesButton.selectedProperty().addListener((o, oldValue, newValue) -> { - byteTag.setValue(newValue ? (byte) 1 : (byte) 0); - saveLevelDat(); - }); - } else { - generateFeaturesButton.setDisable(true); - } - } else { - generateFeaturesButton.setDisable(true); - } + checkTagAndSetListener(tag, generateFeaturesButton); } BorderPane difficultyPane = new BorderPane(); { - Label label = new Label(i18n("world.info.difficulty")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - difficultyPane.setLeft(label); + setLeftLabel(difficultyPane, "world.info.difficulty"); JFXComboBox difficultyBox = new JFXComboBox<>(Difficulty.items); difficultyBox.setDisable(worldManagePage.isReadOnly()); @@ -255,8 +271,7 @@ private void updateControls() { difficultyPane.setRight(difficultyBox); Tag tag = dataTag.get("Difficulty"); - if (tag instanceof ByteTag) { - ByteTag byteTag = (ByteTag) tag; + if (tag instanceof ByteTag byteTag) { Difficulty difficulty = Difficulty.of(byteTag.getValue()); if (difficulty != null) { difficultyBox.setValue(difficulty); @@ -274,86 +289,93 @@ private void updateControls() { } } + OptionToggleButton difficultyLockPane = new OptionToggleButton(); + { + difficultyLockPane.setTitle(i18n("world.info.difficulty_lock")); + difficultyLockPane.setDisable(worldManagePage.isReadOnly()); + + Tag tag = dataTag.get("DifficultyLocked"); + checkTagAndSetListener(tag, difficultyLockPane); + } + basicInfo.getContent().setAll( - worldNamePane, gameVersionPane, randomSeedPane, lastPlayedPane, timePane, - allowCheatsButton, generateFeaturesButton, difficultyPane); + worldNamePane, gameVersionPane, iconPane, seedPane, lastPlayedPane, timePane, + allowCheatsButton, generateFeaturesButton, difficultyPane, difficultyLockPane); rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.basic")), basicInfo); } Tag playerTag = dataTag.get("Player"); - if (playerTag instanceof CompoundTag) { - CompoundTag player = (CompoundTag) playerTag; + if (playerTag instanceof CompoundTag player) { ComponentList playerInfo = new ComponentList(); BorderPane locationPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.location")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - locationPane.setLeft(label); - + setLeftLabel(locationPane, "world.info.player.location"); Label locationLabel = new Label(); - FXUtils.copyOnDoubleClick(locationLabel); - BorderPane.setAlignment(locationLabel, Pos.CENTER_RIGHT); - locationPane.setRight(locationLabel); - - Dimension dim = Dimension.of(player.get("Dimension")); - if (dim != null) { - String posString = dim.formatPosition(player.get("Pos")); - if (posString != null) - locationLabel.setText(posString); - } + setRightTextLabel(locationPane, locationLabel, () -> { + Dimension dim = Dimension.of(player.get("Dimension")); + if (dim != null) { + String posString = dim.formatPosition(player.get("Pos")); + if (posString != null) + return posString; + } + return ""; + }); } BorderPane lastDeathLocationPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.last_death_location")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - lastDeathLocationPane.setLeft(label); - + setLeftLabel(lastDeathLocationPane, "world.info.player.last_death_location"); Label lastDeathLocationLabel = new Label(); - FXUtils.copyOnDoubleClick(lastDeathLocationLabel); - BorderPane.setAlignment(lastDeathLocationLabel, Pos.CENTER_RIGHT); - lastDeathLocationPane.setRight(lastDeathLocationLabel); - - Tag tag = player.get("LastDeathLocation"); - if (tag instanceof CompoundTag) { - Dimension dim = Dimension.of(((CompoundTag) tag).get("dimension")); - if (dim != null) { - String posString = dim.formatPosition(((CompoundTag) tag).get("pos")); - if (posString != null) - lastDeathLocationLabel.setText(posString); + setRightTextLabel(lastDeathLocationPane, lastDeathLocationLabel, () -> { + Tag tag = player.get("LastDeathLocation");// Valid after 22w14a; prior to this version, the game did not record the last death location data. + if (tag instanceof CompoundTag compoundTag) { + Dimension dim = Dimension.of(compoundTag.get("dimension")); + if (dim != null) { + String posString = dim.formatPosition(compoundTag.get("pos")); + if (posString != null) + return posString; + } } - } + return ""; + }); + } BorderPane spawnPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.spawn")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - spawnPane.setLeft(label); - + setLeftLabel(spawnPane, "world.info.player.spawn"); Label spawnLabel = new Label(); - FXUtils.copyOnDoubleClick(spawnLabel); - BorderPane.setAlignment(spawnLabel, Pos.CENTER_RIGHT); - spawnPane.setRight(spawnLabel); - - Dimension dim = Dimension.of(player.get("SpawnDimension")); - if (dim != null) { - Tag x = player.get("SpawnX"); - Tag y = player.get("SpawnY"); - Tag z = player.get("SpawnZ"); - - if (x instanceof IntTag && y instanceof IntTag && z instanceof IntTag) - spawnLabel.setText(dim.formatPosition(((IntTag) x).getValue(), ((IntTag) y).getValue(), ((IntTag) z).getValue())); - } + setRightTextLabel(spawnPane, spawnLabel, () -> { + + Dimension dimension; + if (player.get("respawn") instanceof CompoundTag respawnTag && respawnTag.get("dimension") != null) { // Valid after 25w07a + dimension = Dimension.of(respawnTag.get("dimension")); + Tag posTag = respawnTag.get("pos"); + + if (posTag instanceof IntArrayTag intArrayTag && intArrayTag.length() >= 3) { + return dimension.formatPosition(intArrayTag.getValue(0), intArrayTag.getValue(1), intArrayTag.getValue(2)); + } + } else if (player.get("SpawnX") instanceof IntTag intX + && player.get("SpawnY") instanceof IntTag intY + && player.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a + // SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld. + dimension = Dimension.of(player.get("SpawnDimension") == null ? new IntTag("SpawnDimension", 0) : player.get("SpawnDimension")); + if (dimension == null) { + return ""; + } + + return dimension.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()); + } + + return ""; + }); } BorderPane playerGameTypePane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.game_type")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - playerGameTypePane.setLeft(label); + setLeftLabel(playerGameTypePane, "world.info.player.game_type"); JFXComboBox gameTypeBox = new JFXComboBox<>(GameType.items); gameTypeBox.setDisable(worldManagePage.isReadOnly()); @@ -364,8 +386,7 @@ private void updateControls() { Tag hardcoreTag = dataTag.get("hardcore"); boolean isHardcore = hardcoreTag instanceof ByteTag && ((ByteTag) hardcoreTag).getValue() == 1; - if (tag instanceof IntTag) { - IntTag intTag = (IntTag) tag; + if (tag instanceof IntTag intTag) { GameType gameType = GameType.of(intTag.getValue(), isHardcore); if (gameType != null) { gameTypeBox.setValue(gameType); @@ -395,33 +416,13 @@ private void updateControls() { BorderPane healthPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.health")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - healthPane.setLeft(label); - + setLeftLabel(healthPane, "world.info.player.health"); JFXTextField healthField = new JFXTextField(); - healthField.setDisable(worldManagePage.isReadOnly()); - healthField.setPrefWidth(50); - healthField.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(healthField, Pos.CENTER_RIGHT); - healthPane.setRight(healthField); + setRightTextField(healthPane, healthField, 50); Tag tag = player.get("Health"); - if (tag instanceof FloatTag) { - FloatTag floatTag = (FloatTag) tag; - healthField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue())); - - healthField.textProperty().addListener((o, oldValue, newValue) -> { - if (newValue != null) { - try { - floatTag.setValue(Float.parseFloat(newValue)); - saveLevelDat(); - } catch (Throwable ignored) { - } - } - }); - FXUtils.setValidateWhileTextChanged(healthField, true); - healthField.setValidators(new DoubleValidator(i18n("input.number"), true)); + if (tag instanceof FloatTag floatTag) { + setTagAndTextField(floatTag, healthField); } else { healthField.setDisable(true); } @@ -429,33 +430,13 @@ private void updateControls() { BorderPane foodLevelPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.food_level")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - foodLevelPane.setLeft(label); - + setLeftLabel(foodLevelPane, "world.info.player.food_level"); JFXTextField foodLevelField = new JFXTextField(); - foodLevelField.setDisable(worldManagePage.isReadOnly()); - foodLevelField.setPrefWidth(50); - foodLevelField.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(foodLevelField, Pos.CENTER_RIGHT); - foodLevelPane.setRight(foodLevelField); + setRightTextField(foodLevelPane, foodLevelField, 50); Tag tag = player.get("foodLevel"); - if (tag instanceof IntTag) { - IntTag intTag = (IntTag) tag; - foodLevelField.setText(String.valueOf(intTag.getValue())); - - foodLevelField.textProperty().addListener((o, oldValue, newValue) -> { - if (newValue != null) { - try { - intTag.setValue(Integer.parseInt(newValue)); - saveLevelDat(); - } catch (Throwable ignored) { - } - } - }); - FXUtils.setValidateWhileTextChanged(foodLevelField, true); - foodLevelField.setValidators(new NumberValidator(i18n("input.number"), true)); + if (tag instanceof IntTag intTag) { + setTagAndTextField(intTag, foodLevelField); } else { foodLevelField.setDisable(true); } @@ -463,33 +444,13 @@ private void updateControls() { BorderPane xpLevelPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.xp_level")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - xpLevelPane.setLeft(label); - + setLeftLabel(xpLevelPane, "world.info.player.xp_level"); JFXTextField xpLevelField = new JFXTextField(); - xpLevelField.setDisable(worldManagePage.isReadOnly()); - xpLevelField.setPrefWidth(50); - xpLevelField.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(xpLevelField, Pos.CENTER_RIGHT); - xpLevelPane.setRight(xpLevelField); + setRightTextField(xpLevelPane, xpLevelField, 50); Tag tag = player.get("XpLevel"); - if (tag instanceof IntTag) { - IntTag intTag = (IntTag) tag; - xpLevelField.setText(String.valueOf(intTag.getValue())); - - xpLevelField.textProperty().addListener((o, oldValue, newValue) -> { - if (newValue != null) { - try { - intTag.setValue(Integer.parseInt(newValue)); - saveLevelDat(); - } catch (Throwable ignored) { - } - } - }); - FXUtils.setValidateWhileTextChanged(xpLevelField, true); - xpLevelField.setValidators(new NumberValidator(i18n("input.number"), true)); + if (tag instanceof IntTag intTag) { + setTagAndTextField(intTag, xpLevelField); } else { xpLevelField.setDisable(true); } @@ -504,6 +465,88 @@ private void updateControls() { } } + private void setLeftLabel(BorderPane borderPane, @PropertyKey(resourceBundle = "assets.lang.I18N") String key) { + Label label = new Label(i18n(key)); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + borderPane.setLeft(label); + } + + private void setRightTextField(BorderPane borderPane, JFXTextField textField, int perfWidth) { + textField.setDisable(worldManagePage.isReadOnly()); + textField.setPrefWidth(perfWidth); + textField.setAlignment(Pos.CENTER_RIGHT); + borderPane.setRight(textField); + } + + private void setRightTextLabel(BorderPane borderPane, Label label, Callable setNameCall) { + FXUtils.copyOnDoubleClick(label); + BorderPane.setAlignment(label, Pos.CENTER_RIGHT); + try { + label.setText(setNameCall.call()); + } catch (Exception e) { + LOG.warning("Exception happened when setting name", e); + } + borderPane.setRight(label); + } + + private void checkTagAndSetListener(Tag tag, OptionToggleButton toggleButton) { + if (tag instanceof ByteTag byteTag) { + byte value = byteTag.getValue(); + if (value == 0 || value == 1) { + toggleButton.setSelected(value == 1); + toggleButton.selectedProperty().addListener((o, oldValue, newValue) -> { + try { + byteTag.setValue((byte) (newValue ? 1 : 0)); + saveLevelDat(); + } catch (Exception e) { + toggleButton.setSelected(oldValue); + LOG.warning("Exception happened when saving level.dat", e); + } + }); + } else { + toggleButton.setDisable(true); + } + } else { + toggleButton.setDisable(true); + } + } + + private void setTagAndTextField(IntTag intTag, JFXTextField jfxTextField) { + jfxTextField.setText(String.valueOf(intTag.getValue())); + + jfxTextField.textProperty().addListener((o, oldValue, newValue) -> { + if (newValue != null) { + try { + intTag.setValue(Integer.parseInt(newValue)); + saveLevelDat(); + } catch (Exception e) { + jfxTextField.setText(oldValue); + LOG.warning("Exception happened when saving level.dat", e); + } + } + }); + FXUtils.setValidateWhileTextChanged(jfxTextField, true); + jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true)); + } + + private void setTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) { + jfxTextField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue())); + + jfxTextField.textProperty().addListener((o, oldValue, newValue) -> { + if (newValue != null) { + try { + floatTag.setValue(Float.parseFloat(newValue)); + saveLevelDat(); + } catch (Exception e) { + jfxTextField.setText(oldValue); + LOG.warning("Exception happened when saving level.dat", e); + } + } + }); + FXUtils.setValidateWhileTextChanged(jfxTextField, true); + jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true)); + } + private void saveLevelDat() { LOG.info("Saving level.dat of world " + world.getWorldName()); try { @@ -513,54 +556,34 @@ private void saveLevelDat() { } } - private static final class Dimension { + private record Dimension(String name) { static final Dimension OVERWORLD = new Dimension(null); static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether")); static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end")); - final String name; - static Dimension of(Tag tag) { - if (tag instanceof IntTag) { - switch (((IntTag) tag).getValue()) { - case 0: - return OVERWORLD; - case 1: - return THE_NETHER; - case 2: - return THE_END; - default: - return null; - } - } else if (tag instanceof StringTag) { - String id = ((StringTag) tag).getValue(); - switch (id) { - case "overworld": - case "minecraft:overworld": - return OVERWORLD; - case "the_nether": - case "minecraft:the_nether": - return THE_NETHER; - case "the_end": - case "minecraft:the_end": - return THE_END; - default: - return new Dimension(id); - } + if (tag instanceof IntTag intTag) { + return switch (intTag.getValue()) { + case 0 -> OVERWORLD; + case 1 -> THE_NETHER; + case 2 -> THE_END; + default -> null; + }; + } else if (tag instanceof StringTag stringTag) { + String id = stringTag.getValue(); + return switch (id) { + case "overworld", "minecraft:overworld" -> OVERWORLD; + case "the_nether", "minecraft:the_nether" -> THE_NETHER; + case "the_end", "minecraft:the_end" -> THE_END; + default -> new Dimension(id); + }; } else { return null; } } - private Dimension(String name) { - this.name = name; - } - String formatPosition(Tag tag) { - if (tag instanceof ListTag) { - ListTag listTag = (ListTag) tag; - if (listTag.size() != 3) - return null; + if (tag instanceof ListTag listTag && listTag.size() == 3) { Tag x = listTag.get(0); Tag y = listTag.get(1); @@ -575,8 +598,7 @@ String formatPosition(Tag tag) { return null; } - if (tag instanceof IntArrayTag) { - IntArrayTag intArrayTag = (IntArrayTag) tag; + if (tag instanceof IntArrayTag intArrayTag) { int x = intArrayTag.getValue(0); int y = intArrayTag.getValue(1); @@ -609,7 +631,7 @@ private enum Difficulty { static final ObservableList items = FXCollections.observableList(Arrays.asList(values())); static Difficulty of(int d) { - return d >= 0 && d <= items.size() ? items.get(d) : null; + return (d >= 0 && d < items.size()) ? items.get(d) : null; } @Override @@ -633,4 +655,51 @@ public String toString() { return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT)); } } + + private void changeWorldIcon() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("world.icon.choose.title")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png")); + fileChooser.setInitialFileName("icon.png"); + + File file = fileChooser.showOpenDialog(Controllers.getStage()); + if (file == null) return; + + Image image; + try { + image = FXUtils.loadImage(file.toPath()); + } catch (Exception e) { + LOG.warning("Failed to load image", e); + Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR); + return; + } + if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) { + Path output = world.getFile().resolve("icon.png"); + saveImage(image, output); + } else { + Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR); + } + } + + private void saveImage(Image image, Path path) { + Image oldImage = iconImageView.getImage(); + try { + PNGJavaFXUtils.writeImage(image, path); + iconImageView.setImage(image); + Controllers.showToast(i18n("world.icon.change.succeed.toast")); + } catch (IOException e) { + LOG.warning("Failed to save world icon " + e.getMessage()); + iconImageView.setImage(oldImage); + } + } + + private void clearWorldIcon() { + Path output = world.getFile().resolve("icon.png"); + try { + Files.deleteIfExists(output); + iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png")); + } catch (IOException e) { + LOG.warning("Failed to delete world icon " + e.getMessage()); + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java index 7e8932bc8a..efcf04596d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java @@ -19,23 +19,13 @@ import javafx.scene.control.Control; import javafx.scene.control.Skin; -import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.World; -import org.jackhuang.hmcl.game.WorldLockedException; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; -import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.io.FileUtils; import java.nio.file.Path; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - public final class WorldListItem extends Control { private final World world; private final Path backupsDir; @@ -61,34 +51,15 @@ public World getWorld() { } public void export() { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle(i18n("world.export.title")); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip")); - fileChooser.setInitialFileName(world.getWorldName()); - Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage())); - if (file == null) { - return; - } - - Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish))); + WorldManageUIUtils.export(world); } public void delete() { - Controllers.confirm( - i18n("button.remove.confirm"), - i18n("world.delete"), - () -> Task.runAsync(world::delete) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception == null) { - parent.remove(this); - } else if (exception instanceof WorldLockedException) { - Controllers.dialog(i18n("world.locked.failed"), null, MessageType.WARNING); - } else { - Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageType.WARNING); - } - }).start(), - null - ); + WorldManageUIUtils.delete(world, () -> parent.remove(this)); + } + + public void copy() { + WorldManageUIUtils.copyWorld(world, parent::refresh); } public void reveal() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java index ee1dfa1843..45a3a3bdd5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java @@ -35,6 +35,7 @@ import org.jackhuang.hmcl.util.i18n.I18n; import java.time.Instant; +import java.util.stream.Stream; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; @@ -143,11 +144,24 @@ public void showPopupMenu(JFXPopup.PopupHPosition hPosition, double initOffsetX, } } + IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), item::export, popup); + IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), item::delete, popup); + IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), item::copy, popup); + boolean worldLocked = world.isLocked(); + Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem) + .forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked)); + + popupMenu.getContent().addAll( + new MenuSeparator(), + exportMenuItem, + deleteMenuItem, + duplicateMenuItem + ); + popupMenu.getContent().addAll( new MenuSeparator(), - new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), item::export, popup), - new IconedMenuItem(SVG.DELETE, i18n("world.delete"), item::delete, popup), - new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup)); + new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup) + ); JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, 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 3729e8f8a3..848beaeed3 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 @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.versions; import com.jfoenix.controls.JFXPopup; +import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; @@ -27,6 +28,7 @@ import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.TransitionPane; @@ -34,6 +36,7 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.ChunkBaseApp; +import org.jackhuang.hmcl.util.StringUtils; import java.io.IOException; import java.nio.channels.FileChannel; @@ -53,6 +56,8 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final Profile profile; private final String id; + private boolean loadFailed = false; + private final TabHeader header; private final TabHeader.Tab worldInfoTab = new TabHeader.Tab<>("worldInfoPage"); private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); @@ -65,25 +70,25 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco public WorldManagePage(World world, Path backupsDir, Profile profile, String id) { this.world = world; this.backupsDir = backupsDir; - this.profile = profile; this.id = id; + sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); + try { + world.reloadLevelDat(); + } catch (IOException e) { + LOG.warning("Can not load world level.dat of world: " + world.getFile(), e); + loadFailed = true; + } + this.worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); this.worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); this.datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); - this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", world.getWorldName()))); + this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())))); this.header = new TabHeader(transitionPane, worldInfoTab, worldBackupsTab); header.select(worldInfoTab); - // Does it need to be done in the background? - try { - sessionLockChannel = world.lock(); - LOG.info("Acquired lock on world " + world.getFileName()); - } catch (IOException ignored) { - } - setCenter(transitionPane); BorderPane left = new BorderPane(); @@ -106,37 +111,81 @@ public WorldManagePage(World world, Path backupsDir, Profile profile, String id) AdvancedListBox toolbar = new AdvancedListBox(); if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { - toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, null); + toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, advancedListItem -> advancedListItem.setDisable(isReadOnly())); toolbar.addNavigationDrawerItem(i18n("version.launch_script"), SVG.SCRIPT, this::generateLaunchScript, null); } if (ChunkBaseApp.isSupported(world)) { - PopupMenu popupMenu = new PopupMenu(); - JFXPopup popup = new JFXPopup(popupMenu); + PopupMenu chunkBasePopupMenu = new PopupMenu(); + JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu); - popupMenu.getContent().addAll( - 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) + + chunkBasePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup), + new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup), + new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup) ); if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) { - popupMenu.getContent().add( - new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup)); + chunkBasePopupMenu.getContent().add( + new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup)); } toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem -> chunkBaseMenuItem.setOnAction(e -> - popup.show(chunkBaseMenuItem, + chunkBasePopup.show(chunkBaseMenuItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, chunkBaseMenuItem.getWidth(), 0))); } + toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()), null); + { + PopupMenu managePopupMenu = new PopupMenu(); + JFXPopup managePopup = new JFXPopup(managePopupMenu); + + managePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup), + new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup), + new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup) + ); + + toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem -> + { + managePopupMenuItem.setOnAction(e -> + managePopup.show(managePopupMenuItem, + JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, + managePopupMenuItem.getWidth(), 0)); + managePopupMenuItem.setDisable(isReadOnly()); + }); + + } + BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); left.setBottom(toolbar); this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); + this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); + } + + private void onNavigated(Navigator.NavigationEvent event) { + if (loadFailed) { + Platform.runLater(() -> { + fireEvent(new PageCloseEvent()); + Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR); + }); + return; + } + if (sessionLockChannel == null || !sessionLockChannel.isOpen()) { + sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); + } + } + + public void onExited(Navigator.NavigationEvent event) { + try { + WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); + } catch (IOException ignored) { + } } @Override @@ -156,19 +205,6 @@ public boolean isReadOnly() { return sessionLockChannel == null; } - public void onExited(Navigator.NavigationEvent event) { - if (sessionLockChannel != null) { - try { - sessionLockChannel.close(); - LOG.info("Releases the lock on world " + world.getFileName()); - } catch (IOException e) { - LOG.warning("Failed to close session lock channel", e); - } - - sessionLockChannel = null; - } - } - public void launch() { fireEvent(new PageCloseEvent()); Versions.launchAndEnterWorld(profile, id, world.getFileName()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java new file mode 100644 index 0000000000..761144ce4d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -0,0 +1,152 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 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.stage.FileChooser; +import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.game.WorldLockedException; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.construct.InputDialogPane; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class WorldManageUIUtils { + private WorldManageUIUtils() { + } + + public static void delete(World world, Runnable runnable) { + delete(world, runnable, null); + } + + public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) { + Controllers.confirm( + i18n("button.remove.confirm"), + i18n("world.delete"), + () -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel)) + .thenRunAsync(world::delete) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + runnable.run(); + } else if (exception instanceof WorldLockedException) { + Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); + } else { + Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); + } + }).start(), + null + ); + } + + public static void export(World world) { + export(world, null); + } + + public static void export(World world, FileChannel sessionLockChannel) { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("world.export.title")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip")); + fileChooser.setInitialFileName(world.getWorldName()); + Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage())); + if (file == null) { + return; + } + + try { + closeSessionLockChannel(world, sessionLockChannel); + } catch (IOException e) { + return; + } + + Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish))); + } + + public static void copyWorld(World world, Runnable runnable) { + Path worldPath = world.getFile(); + Controllers.dialog(new InputDialogPane( + i18n("world.duplicate.prompt"), + "", + (result, resolve, reject) -> { + if (StringUtils.isBlank(result)) { + reject.accept(i18n("world.duplicate.failed.empty_name")); + return; + } + + if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) { + reject.accept(i18n("world.duplicate.failed.invalid_name")); + return; + } + + Path targetDir = worldPath.resolveSibling(result); + if (Files.exists(targetDir)) { + reject.accept(i18n("world.duplicate.failed.already_exists")); + return; + } + + Task.runAsync(Schedulers.io(), () -> world.copy(result)) + .thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))) + .thenAcceptAsync(Schedulers.javafx(), (Void) -> { + if (runnable != null) { + runnable.run(); + } + } + ).whenComplete(Schedulers.javafx(), (throwable) -> { + if (throwable == null) { + resolve.run(); + } else { + reject.accept(i18n("world.duplicate.failed")); + LOG.warning("Failed to duplicate world " + world.getFile(), throwable); + } + }) + .start(); + })); + } + + public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException { + if (sessionLockChannel != null) { + try { + sessionLockChannel.close(); + LOG.info("Closed session lock channel of the world " + world.getFileName()); + } catch (IOException e) { + throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e); + } + } + } + + public static FileChannel getSessionLockChannel(World world) { + try { + FileChannel lock = world.lock(); + LOG.info("Acquired lock on world " + world.getFileName()); + return lock; + } catch (IOException ignored) { + return null; + } + } + +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index e4bf023916..7cbadeed02 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1131,6 +1131,13 @@ world.chunkbase.end_city=End City world.chunkbase.seed_map=Seed Map world.chunkbase.stronghold=Stronghold world.chunkbase.nether_fortress=Nether Fortress +world.duplicate=Duplicate the World +world.duplicate.prompt=Please enter the name of the duplicated world +world.duplicate.failed.already_exists=Directory already exists +world.duplicate.failed.empty_name=Name cannot be empty +world.duplicate.failed.invalid_name=Name contains invalid characters +world.duplicate.failed=Failed to duplicate the world +world.duplicate.success.toast=Successfully duplicated the world world.datapack=Datapacks world.datetime=Last played on %s world.delete=Delete the World @@ -1143,6 +1150,15 @@ world.export.location=Save As world.export.wizard=Export World "%s" world.extension=World Archive world.game_version=Game Version +world.icon=World Icon +world.icon.change=Change world icon +world.icon.change.fail.load.title=Failed to parse image +world.icon.change.fail.load.text=This image appears to be corrupted, and HMCL cannot parse it. +world.icon.change.fail.not_64x64.title=Image size error +world.icon.change.fail.not_64x64.text=The image resolution is %d×%d instead of 64×64. Please provide a 64×64 image and try again. +world.icon.change.succeed.toast=Successfully updated the world icon. +world.icon.change.tip=A 64×64 PNG image is required. Images with an incorrect resolution cannot be parsed by Minecraft. +world.icon.choose.title=Select world icon world.import.already_exists=This world already exists. world.import.choose=Choose world archive you want to import world.import.failed=Failed to import this world: %s @@ -1157,6 +1173,7 @@ world.info.difficulty.peaceful=Peaceful world.info.difficulty.easy=Easy world.info.difficulty.normal=Normal world.info.difficulty.hard=Hard +world.info.difficulty_lock=Lock Difficulty world.info.failed=Failed to read the world info world.info.game_version=Game Version world.info.last_played=Last Played @@ -1177,6 +1194,7 @@ world.info.player.xp_level=Experience Level world.info.random_seed=Seed world.info.time=Game Time world.info.time.format=%s days +world.load.fail=Failed to load world world.locked=In use world.locked.failed=The world is currently in use. Please close the game and try again. world.manage=Worlds @@ -1610,4 +1628,4 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s wizard.prev=< Prev wizard.failed=Failed wizard.finish=Finish -wizard.next=Next > +wizard.next=Next > \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 2703c5b96c..e21cda35d7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1111,6 +1111,9 @@ world.chunkbase.end_city=Ciudad del End world.chunkbase.seed_map=Vista previa de la generación mundial world.chunkbase.stronghold=Fortaleza world.chunkbase.nether_fortress=Fortaleza del Nether +world.duplicate.failed.already_exists=El directorio ya existe +world.duplicate.failed.empty_name=El nombre no puede estar vacío +world.duplicate.failed.invalid_name=El nombre contiene caracteres no válidos world.datapack=Paquetes de datos world.datetime=Jugado por última vez en %s world.delete=Eliminar este mundo diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 4cd9e49690..6efa9e975e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1104,6 +1104,9 @@ world.chunkbase.end_city=Город Края world.chunkbase.seed_map=Предпросмотр генерации мира world.chunkbase.stronghold=Крепость world.chunkbase.nether_fortress=Крепость Нижнего мира +world.duplicate.failed.already_exists=Папка уже существует +world.duplicate.failed.empty_name=Название не может быть пустым +world.duplicate.failed.invalid_name=Название содержит недопустимые символы world.delete=Удалить этот мир world.delete.failed=Не удалось удалить мир.\n%s world.datapack=Наборы данных diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 9c2e2bf1ab..73f052853f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1048,6 +1048,9 @@ world.chunkbase.end_city=Кінцеве місто world.chunkbase.seed_map=Карта насіння world.chunkbase.stronghold=Фортеця world.chunkbase.nether_fortress=Форт Незеру +world.duplicate.failed.already_exists=Каталог вже існує +world.duplicate.failed.empty_name=Назва не може бути порожньою +world.duplicate.failed.invalid_name=Назва містить недійсні символи world.datapack=Datapacks world.datetime=Останній раз грали %s world.delete=Видалити цей світ diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 5f39568b3d..190204469c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -919,6 +919,13 @@ world.chunkbase.end_city=終界城地圖 world.chunkbase.seed_map=種子地圖 world.chunkbase.stronghold=要塞地圖 world.chunkbase.nether_fortress=地獄要塞地圖 +world.duplicate=複製此世界 +world.duplicate.prompt=輸入複製後的世界名稱 +world.duplicate.failed.already_exists=目錄已存在 +world.duplicate.failed.empty_name=名稱不能為空 +world.duplicate.failed.invalid_name=名稱中包含無效字元 +world.duplicate.failed=複製世界失敗 +world.duplicate.success.toast=複製世界成功 world.datapack=資料包管理 world.datetime=上一次遊戲時間: %s world.delete=刪除此世界 @@ -930,6 +937,15 @@ world.export.title=選取該世界的儲存位置 world.export.location=儲存到 world.export.wizard=匯出世界「%s」 world.extension=世界壓縮檔 +world.icon=世界圖示 +world.icon.change=修改世界圖示 +world.icon.change.fail.load.title=圖片解析失敗 +world.icon.change.fail.load.text=該圖片似乎已損毀,HMCL 無法解析它 +world.icon.change.fail.not_64x64.title=圖片大小錯誤 +world.icon.change.fail.not_64x64.text=該圖片的解析度為 %d×%d,而不是 64×64,請提供一張 64×64 解析度的圖片再次嘗試 +world.icon.change.succeed.toast=世界圖示修改成功 +world.icon.change.tip=請提供一張 64×64 PNG 格式的圖片。錯誤解析度的圖片將無法被 Minecraft 解析。 +world.icon.choose.title=選擇世界圖示 world.import.already_exists=此世界已經存在 world.import.choose=選取要匯入的世界壓縮檔 world.import.failed=無法匯入此世界: %s @@ -944,6 +960,7 @@ world.info.difficulty.peaceful=和平 world.info.difficulty.easy=簡單 world.info.difficulty.normal=普通 world.info.difficulty.hard=困難 +world.info.difficulty_lock=鎖定難易度 world.info.failed=讀取世界資訊失敗 world.info.game_version=遊戲版本 world.info.last_played=上一次遊戲時間 @@ -964,6 +981,7 @@ world.info.player.xp_level=經驗等級 world.info.random_seed=種子碼 world.info.time=遊戲內時間 world.info.time.format=%s 天 +world.load.fail=世界載入失敗 world.locked=使用中 world.locked.failed=該世界正在使用中,請關閉遊戲後重試。 world.game_version=遊戲版本 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 fddd5f71b2..4bc19a5e19 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -923,6 +923,13 @@ world.chunkbase.end_city=末地城地图 world.chunkbase.seed_map=种子地图 world.chunkbase.stronghold=要塞地图 world.chunkbase.nether_fortress=下界要塞地图 +world.duplicate=复制此世界 +world.duplicate.prompt=输入复制后的世界名称 +world.duplicate.failed.already_exists=文件夹已存在 +world.duplicate.failed.empty_name=名称不能为空 +world.duplicate.failed.invalid_name=名称中包含非法字符 +world.duplicate.failed=复制世界失败 +world.duplicate.success.toast=复制世界成功 world.datapack=数据包管理 world.datetime=上一次游戏时间: %s world.delete=删除此世界 @@ -935,6 +942,15 @@ world.export.location=保存到 world.export.wizard=导出世界“%s” world.extension=世界压缩包 world.game_version=游戏版本 +world.icon=世界图标 +world.icon.change=修改世界图标 +world.icon.change.fail.load.title=图片解析出错 +world.icon.change.fail.load.text=该图片似乎已损坏,HMCL 无法解析它 +world.icon.change.fail.not_64x64.title=图片大小错误 +world.icon.change.fail.not_64x64.text=该图片的分辨率为 %d×%d,而不是 64×64,请提供一张 64×64 分辨率的图片再次尝试 +world.icon.change.succeed.toast=世界图标修改成功 +world.icon.change.tip=请提供一张 64×64 PNG 格式的图片。错误分辨率的图片将无法被 Minecraft 解析。 +world.icon.choose.title=选择世界图标 world.import.already_exists=此世界已经存在 world.import.choose=选择要导入的世界压缩包 world.import.failed=无法导入此世界:%s @@ -949,6 +965,7 @@ world.info.difficulty.peaceful=和平 world.info.difficulty.easy=简单 world.info.difficulty.normal=普通 world.info.difficulty.hard=困难 +world.info.difficulty_lock=锁定难度 world.info.failed=读取世界信息失败 world.info.game_version=游戏版本 world.info.last_played=上一次游戏时间 @@ -969,6 +986,7 @@ world.info.player.xp_level=经验等级 world.info.random_seed=种子 world.info.time=游戏内时间 world.info.time.format=%s 天 +world.load.fail=世界加载失败 world.locked=使用中 world.locked.failed=该世界正在使用中,请关闭游戏后重试。 world.manage=世界管理 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 7273d2d20c..24943caf07 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -18,10 +18,7 @@ package org.jackhuang.hmcl.game; import com.github.steveice10.opennbt.NBTIO; -import com.github.steveice10.opennbt.tag.builtin.CompoundTag; -import com.github.steveice10.opennbt.tag.builtin.LongTag; -import com.github.steveice10.opennbt.tag.builtin.StringTag; -import com.github.steveice10.opennbt.tag.builtin.Tag; +import com.github.steveice10.opennbt.tag.builtin.*; import javafx.scene.image.Image; import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -48,13 +45,10 @@ public final class World { private final Path file; private String fileName; - private String worldName; - private GameVersionNumber gameVersion; - private long lastPlayed; + private CompoundTag levelData; private Image icon; - private Long seed; - private boolean largeBiomes; private boolean isLocked; + private Path levelDataPath; public World(Path file) throws IOException { this.file = file; @@ -67,24 +61,6 @@ else if (Files.isRegularFile(file)) throw new IOException("Path " + file + " cannot be recognized as a Minecraft world"); } - private void loadFromDirectory() throws IOException { - fileName = FileUtils.getName(file); - Path levelDat = file.resolve("level.dat"); - loadWorldInfo(levelDat); - isLocked = isLocked(getSessionLockFile()); - - Path iconFile = file.resolve("icon.png"); - if (Files.isRegularFile(iconFile)) { - try (InputStream inputStream = Files.newInputStream(iconFile)) { - icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) - throw icon.getException(); - } catch (Exception e) { - LOG.warning("Failed to load world icon", e); - } - } - } - public Path getFile() { return file; } @@ -94,7 +70,16 @@ public String getFileName() { } public String getWorldName() { - return worldName; + CompoundTag data = levelData.get("Data"); + StringTag levelNameTag = data.get("LevelName"); + return levelNameTag.getValue(); + } + + public void setWorldName(String worldName) throws IOException { + if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) { + levelNameTag.setValue(worldName); + writeLevelDat(levelData); + } } public Path getLevelDatFile() { @@ -105,20 +90,53 @@ public Path getSessionLockFile() { return file.resolve("session.lock"); } + public CompoundTag getLevelData() { + return levelData; + } + public long getLastPlayed() { - return lastPlayed; + CompoundTag data = levelData.get("Data"); + LongTag lastPlayedTag = data.get("LastPlayed"); + return lastPlayedTag.getValue(); } public @Nullable GameVersionNumber getGameVersion() { - return gameVersion; + 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; } public @Nullable Long getSeed() { - return seed; + CompoundTag data = levelData.get("Data"); + if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { //Valid after 1.16 + return seedTag.getValue(); + } else if (data.get("RandomSeed") instanceof LongTag seedTag) { //Valid before 1.16 + return seedTag.getValue(); + } + return null; } public boolean isLargeBiomes() { - return largeBiomes; + CompoundTag data = levelData.get("Data"); + if (data.get("generatorName") instanceof StringTag generatorNameTag) { //Valid before 1.16 + return "largeBiomes".equals(generatorNameTag.getValue()); + } else { + if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag + && worldGenSettingsTag.get("dimensions") instanceof CompoundTag dimensionsTag + && 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 + return largeBiomesTag.getValue() == (byte) 1; + } else if (generatorTag.get("settings") instanceof StringTag settingsTag) { //Valid after 1.16.2 + return "minecraft:large_biomes".equals(settingsTag.getValue()); + } + } + return false; + } } public Image getIcon() { @@ -129,12 +147,38 @@ public boolean isLocked() { return isLocked; } + private void loadFromDirectory() throws IOException { + fileName = FileUtils.getName(file); + Path levelDat = file.resolve("level.dat"); + if (!Files.exists(levelDat)) { // version 20w14infinite + levelDat = file.resolve("special_level.dat"); + } + loadAndCheckLevelDat(levelDat); + this.levelDataPath = levelDat; + isLocked = isLocked(getSessionLockFile()); + + Path iconFile = file.resolve("icon.png"); + if (Files.isRegularFile(iconFile)) { + try (InputStream inputStream = Files.newInputStream(iconFile)) { + icon = new Image(inputStream, 64, 64, true, false); + if (icon.isError()) + throw icon.getException(); + } catch (Exception e) { + LOG.warning("Failed to load world icon", e); + } + } + } + private void loadFromZipImpl(Path root) throws IOException { Path levelDat = root.resolve("level.dat"); - if (!Files.exists(levelDat)) - throw new IOException("Not a valid world zip file since level.dat cannot be found."); + if (!Files.exists(levelDat)) { //version 20w14infinite + levelDat = root.resolve("special_level.dat"); + } + if (!Files.exists(levelDat)) { + throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); + } - loadWorldInfo(levelDat); + loadAndCheckLevelDat(levelDat); Path iconFile = root.resolve("icon.png"); if (Files.isRegularFile(iconFile)) { @@ -167,50 +211,22 @@ private void loadFromZip() throws IOException { } } - private void loadWorldInfo(Path levelDat) throws IOException { - CompoundTag nbt = parseLevelDat(levelDat); - - CompoundTag data = nbt.get("Data"); + private void loadAndCheckLevelDat(Path levelDat) throws IOException { + this.levelData = parseLevelDat(levelDat); + CompoundTag data = levelData.get("Data"); if (data == null) throw new IOException("level.dat missing Data"); - if (data.get("LevelName") instanceof StringTag) - worldName = data.get("LevelName").getValue(); - else + if (!(data.get("LevelName") instanceof StringTag)) throw new IOException("level.dat missing LevelName"); - if (data.get("LastPlayed") instanceof LongTag) - lastPlayed = data.get("LastPlayed").getValue(); - else + if (!(data.get("LastPlayed") instanceof LongTag)) throw new IOException("level.dat missing LastPlayed"); + } - gameVersion = null; - if (data.get("Version") instanceof CompoundTag) { - CompoundTag version = data.get("Version"); - - if (version.get("Name") instanceof StringTag) - gameVersion = GameVersionNumber.asGameVersion(version.get("Name").getValue()); - } - - Tag worldGenSettings = data.get("WorldGenSettings"); - if (worldGenSettings instanceof CompoundTag) { - Tag seedTag = ((CompoundTag) worldGenSettings).get("seed"); - if (seedTag instanceof LongTag) { - seed = ((LongTag) seedTag).getValue(); - } - } - if (seed == null) { - Tag seedTag = data.get("RandomSeed"); - if (seedTag instanceof LongTag) { - seed = ((LongTag) seedTag).getValue(); - } - } - - // FIXME: Only work for 1.15 and below - if (data.get("generatorName") instanceof StringTag) { - largeBiomes = "largeBiomes".equals(data.get("generatorName").getValue()); - } else { - largeBiomes = false; + public void reloadLevelDat() throws IOException { + if (levelDataPath != null) { + loadAndCheckLevelDat(this.levelDataPath); } } @@ -219,10 +235,9 @@ public void rename(String newName) throws IOException { throw new IOException("Not a valid world directory"); // Change the name recorded in level.dat - CompoundTag nbt = readLevelDat(); - CompoundTag data = nbt.get("Data"); + CompoundTag data = levelData.get("Data"); data.put(new StringTag("LevelName", newName)); - writeLevelDat(nbt); + writeLevelDat(levelData); // then change the folder's name Files.move(file, file.resolveSibling(newName)); @@ -283,11 +298,19 @@ public void delete() throws IOException { FileUtils.forceDelete(file); } - public CompoundTag readLevelDat() throws IOException { - if (!Files.isDirectory(file)) + public void copy(String newName) throws IOException { + if (!Files.isDirectory(file)) { throw new IOException("Not a valid world directory"); + } + + if (isLocked()) { + throw new WorldLockedException("The world " + getFile() + " has been locked"); + } - return parseLevelDat(getLevelDatFile()); + Path newPath = file.resolveSibling(newName); + FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); + World newWorld = new World(newPath); + newWorld.rename(newName); } public FileChannel lock() throws WorldLockedException {