feat: 优化世界管理界面和世界信息界面 (#4823)

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
This commit is contained in:
mineDiamond
2026-01-07 22:26:17 +08:00
committed by GitHub
parent 8b8a62ccf1
commit 7a4649f0cc
12 changed files with 767 additions and 439 deletions

View File

@@ -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.Cursor;
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 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
{
BorderPane worldNamePane = new BorderPane();
{
Label label = new Label(i18n("world.name"));
worldNamePane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
setLeftLabel(worldNamePane, "world.name");
JFXTextField worldNameField = new JFXTextField();
setRightTextField(worldNamePane, worldNameField, 200);
Label worldNameLabel = new Label();
FXUtils.copyOnDoubleClick(worldNameLabel);
worldNameLabel.setText(world.getWorldName());
BorderPane.setAlignment(worldNameLabel, Pos.CENTER_RIGHT);
worldNamePane.setRight(worldNameLabel);
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()));
left.getChildren().setAll(label, visibilityButton);
Label randomSeedLabel = new Label();
FXUtils.copyOnDoubleClick(randomSeedLabel);
BorderPane.setAlignment(randomSeedLabel, Pos.CENTER_RIGHT);
randomSeedPane.setRight(randomSeedLabel);
Tag tag = worldGenSettings != null ? worldGenSettings.get("seed") : dataTag.get("RandomSeed");
if (tag instanceof LongTag) {
randomSeedLabel.setText(tag.getValue().toString());
{
visibilityButton.setCursor(Cursor.HAND);
visibilityButton.setAlignment(Pos.BOTTOM_RIGHT);
FXUtils.setLimitWidth(visibilityButton, 12);
FXUtils.setLimitHeight(visibilityButton, 12);
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
}
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);
});
Label seedLabel = new Label();
{
FXUtils.copyOnDoubleClick(seedLabel);
seedLabel.setAlignment(Pos.CENTER_RIGHT);
seedLabel.setText(world.getSeed() != null ? world.getSeed().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);
});
}
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 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
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<Difficulty> difficultyBox = new JFXComboBox<>(Difficulty.items);
difficultyBox.setDisable(worldManagePage.isReadOnly());
@@ -255,8 +271,7 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
}
}
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);
setRightTextLabel(spawnPane, 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");
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 (x instanceof IntTag && y instanceof IntTag && z instanceof IntTag)
spawnLabel.setText(dim.formatPosition(((IntTag) x).getValue(), ((IntTag) y).getValue(), ((IntTag) z).getValue()));
}
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<GameType> gameTypeBox = new JFXComboBox<>(GameType.items);
gameTypeBox.setDisable(worldManagePage.isReadOnly());
@@ -364,8 +386,7 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
}
}
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<String> 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 @@ public final class WorldInfoPage extends SpinnerPane {
}
}
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 @@ public final class WorldInfoPage extends SpinnerPane {
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 @@ public final class WorldInfoPage extends SpinnerPane {
static final ObservableList<Difficulty> 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 final class WorldInfoPage extends SpinnerPane {
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());
}
}
}

View File

@@ -19,23 +19,13 @@ package org.jackhuang.hmcl.ui.versions;
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 final class WorldListItem extends Control {
}
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() {

View File

@@ -35,6 +35,7 @@ import org.jackhuang.hmcl.util.ChunkBaseApp;
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 final class WorldListItemSkin extends SkinBase<WorldListItem> {
}
}
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(),
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));
exportMenuItem,
deleteMenuItem,
duplicateMenuItem
);
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup)
);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);

View File

@@ -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.Priority;
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.construct.*;
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<WorldInfoPage> worldInfoTab = new TabHeader.Tab<>("worldInfoPage");
private final TabHeader.Tab<WorldBackupsPage> 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 final class WorldManagePage extends DecoratorAnimatedPage implements Deco
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 final class WorldManagePage extends DecoratorAnimatedPage implements Deco
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());

View File

@@ -0,0 +1,152 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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;
}
}
}

View File

@@ -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
@@ -1614,4 +1632,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 >

View File

@@ -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

View File

@@ -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=Наборы данных

View File

@@ -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=Видалити цей світ

View File

@@ -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=遊戲版本

View File

@@ -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=世界管理

View File

@@ -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,10 +61,100 @@ public final class World {
throw new IOException("Path " + file + " cannot be recognized as a Minecraft world");
}
public Path getFile() {
return file;
}
public String getFileName() {
return fileName;
}
public String getWorldName() {
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() {
return file.resolve("level.dat");
}
public Path getSessionLockFile() {
return file.resolve("session.lock");
}
public CompoundTag getLevelData() {
return levelData;
}
public long getLastPlayed() {
CompoundTag data = levelData.get("Data");
LongTag lastPlayedTag = data.get("LastPlayed");
return lastPlayedTag.getValue();
}
public @Nullable GameVersionNumber getGameVersion() {
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() {
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() {
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() {
return icon;
}
public boolean isLocked() {
return isLocked;
}
private void loadFromDirectory() throws IOException {
fileName = FileUtils.getName(file);
Path levelDat = file.resolve("level.dat");
loadWorldInfo(levelDat);
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");
@@ -85,56 +169,16 @@ public final class World {
}
}
public Path getFile() {
return file;
}
public String getFileName() {
return fileName;
}
public String getWorldName() {
return worldName;
}
public Path getLevelDatFile() {
return file.resolve("level.dat");
}
public Path getSessionLockFile() {
return file.resolve("session.lock");
}
public long getLastPlayed() {
return lastPlayed;
}
public @Nullable GameVersionNumber getGameVersion() {
return gameVersion;
}
public @Nullable Long getSeed() {
return seed;
}
public boolean isLargeBiomes() {
return largeBiomes;
}
public Image getIcon() {
return icon;
}
public boolean isLocked() {
return isLocked;
}
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 @@ public final class World {
}
}
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.<StringTag>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.<LongTag>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.<StringTag>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.<StringTag>get("generatorName").getValue());
} else {
largeBiomes = false;
public void reloadLevelDat() throws IOException {
if (levelDataPath != null) {
loadAndCheckLevelDat(this.levelDataPath);
}
}
@@ -219,10 +235,9 @@ public final class World {
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 final class World {
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");
}
return parseLevelDat(getLevelDatFile());
if (isLocked()) {
throw new WorldLockedException("The world " + getFile() + " has been locked");
}
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 {