diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java new file mode 100644 index 000000000..2bf28f895 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java @@ -0,0 +1,57 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 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.construct; + +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.NamedArg; +import javafx.scene.control.TextInputControl; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; + +public class DoubleValidator extends ValidatorBase { + private final boolean nullable; + + public DoubleValidator() { + this(false); + } + + public DoubleValidator(@NamedArg("nullable") boolean nullable) { + this.nullable = nullable; + } + + public DoubleValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) { + super(message); + this.nullable = nullable; + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + + if (StringUtils.isBlank(textField.getText())) + hasErrors.set(!nullable); + else + hasErrors.set(Lang.toDoubleOrNull(textField.getText()) == null); + } +} 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 new file mode 100644 index 000000000..b5f3a0483 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -0,0 +1,553 @@ +package org.jackhuang.hmcl.ui.versions; + +import com.github.steveice10.opennbt.tag.builtin.*; +import com.jfoenix.controls.JFXComboBox; +import com.jfoenix.controls.JFXTextField; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.ComponentList; +import org.jackhuang.hmcl.ui.construct.DoubleValidator; +import org.jackhuang.hmcl.ui.construct.NumberValidator; +import org.jackhuang.hmcl.ui.construct.OptionToggleButton; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.i18n.Locales; + +import java.io.IOException; +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; +import java.util.logging.Level; + +import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class WorldInfoPage extends StackPane implements DecoratorPage { + private final World world; + private final CompoundTag levelDat; + private final CompoundTag dataTag; + + private final ObjectProperty stateProperty = new SimpleObjectProperty<>(); + + public WorldInfoPage(World world) throws IOException { + this.world = world; + this.levelDat = world.readLevelDat(); + this.dataTag = levelDat.get("Data"); + + CompoundTag worldGenSettings = dataTag.get("WorldGenSettings"); + + stateProperty.set(State.fromTitle(i18n("world.info.title", world.getWorldName()))); + + ScrollPane scrollPane = new ScrollPane(); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS); + getChildren().setAll(scrollPane); + + VBox rootPane = new VBox(); + rootPane.setFillWidth(true); + scrollPane.setContent(rootPane); + FXUtils.smoothScrolling(scrollPane); + rootPane.getStyleClass().add("card-list"); + + ComponentList basicInfo = new ComponentList(); + { + BorderPane worldNamePane = new BorderPane(); + { + Label label = new Label(i18n("world.name")); + worldNamePane.setLeft(label); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + + Label worldNameLabel = new Label(); + worldNameLabel.setText(world.getWorldName()); + BorderPane.setAlignment(worldNameLabel, Pos.CENTER_RIGHT); + worldNamePane.setRight(worldNameLabel); + } + + BorderPane gameVersionPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.game_version")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + gameVersionPane.setLeft(label); + + Label gameVersionLabel = new Label(); + gameVersionLabel.setText(world.getGameVersion()); + BorderPane.setAlignment(gameVersionLabel, Pos.CENTER_RIGHT); + gameVersionPane.setRight(gameVersionLabel); + } + + BorderPane randomSeedPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.random_seed")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + randomSeedPane.setLeft(label); + + Label randomSeedLabel = new Label(); + 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()); + } + } + + BorderPane lastPlayedPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.last_played")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + lastPlayedPane.setLeft(label); + + Label lastPlayedLabel = new Label(); + lastPlayedLabel.setText(Locales.SIMPLE_DATE_FORMAT.get().format(new Date(world.getLastPlayed()))); + BorderPane.setAlignment(lastPlayedLabel, Pos.CENTER_RIGHT); + lastPlayedPane.setRight(lastPlayedLabel); + } + + BorderPane timePane = new BorderPane(); + { + Label label = new Label(i18n("world.info.time")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + timePane.setLeft(label); + + Label timeLabel = new Label(); + 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)); + } + } + + OptionToggleButton allowCheatsButton = new OptionToggleButton(); + { + allowCheatsButton.setTitle(i18n("world.info.allow_cheats")); + 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); + } + } + + OptionToggleButton generateFeaturesButton = new OptionToggleButton(); + { + generateFeaturesButton.setTitle(i18n("world.info.generate_features")); + 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); + } + } + + BorderPane difficultyPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.difficulty")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + difficultyPane.setLeft(label); + + JFXComboBox difficultyBox = new JFXComboBox<>(Difficulty.items); + BorderPane.setAlignment(difficultyBox, Pos.CENTER_RIGHT); + difficultyPane.setRight(difficultyBox); + + Tag tag = dataTag.get("Difficulty"); + if (tag instanceof ByteTag) { + ByteTag byteTag = (ByteTag) tag; + Difficulty difficulty = Difficulty.of(byteTag.getValue()); + if (difficulty != null) { + difficultyBox.setValue(difficulty); + difficultyBox.valueProperty().addListener((o, oldValue, newValue) -> { + if (newValue != null) { + byteTag.setValue((byte) newValue.ordinal()); + saveLevelDat(); + } + }); + } else { + difficultyBox.setDisable(true); + } + } else { + difficultyBox.setDisable(true); + } + } + + basicInfo.getContent().setAll( + worldNamePane, gameVersionPane, randomSeedPane, lastPlayedPane, timePane, + allowCheatsButton, generateFeaturesButton, difficultyPane); + + rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.basic")), basicInfo); + } + + Tag playerTag = dataTag.get("Player"); + if (playerTag instanceof CompoundTag) { + CompoundTag player = (CompoundTag) playerTag; + 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); + + Label locationLabel = new Label(); + 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); + } + } + + BorderPane lastDeathLocationPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.player.last_death_location")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + lastDeathLocationPane.setLeft(label); + + Label lastDeathLocationLabel = new Label(); + 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); + } + } + } + + BorderPane spawnPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.player.spawn")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + spawnPane.setLeft(label); + + Label spawnLabel = new Label(); + 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())); + } + } + + BorderPane playerGameTypePane = new BorderPane(); + { + Label label = new Label(i18n("world.info.player.game_type")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + playerGameTypePane.setLeft(label); + + JFXComboBox gameTypeBox = new JFXComboBox<>(GameType.items); + BorderPane.setAlignment(gameTypeBox, Pos.CENTER_RIGHT); + playerGameTypePane.setRight(gameTypeBox); + + Tag tag = player.get("playerGameType"); + if (tag instanceof IntTag) { + IntTag intTag = (IntTag) tag; + GameType gameType = GameType.of(intTag.getValue()); + if (gameType != null) { + gameTypeBox.setValue(gameType); + gameTypeBox.valueProperty().addListener((o, oldValue, newValue) -> { + if (newValue != null) { + intTag.setValue(newValue.ordinal()); + saveLevelDat(); + } + }); + } else { + gameTypeBox.setDisable(true); + } + } else { + gameTypeBox.setDisable(true); + } + } + + BorderPane healthPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.player.health")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + healthPane.setLeft(label); + + JFXTextField healthField = new JFXTextField(); + healthField.setPrefWidth(50); + healthField.setAlignment(Pos.CENTER_RIGHT); + BorderPane.setAlignment(healthField, Pos.CENTER_RIGHT); + healthPane.setRight(healthField); + + 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)); + } else { + healthField.setDisable(true); + } + } + + BorderPane foodLevelPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.player.food_level")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + foodLevelPane.setLeft(label); + + JFXTextField foodLevelField = new JFXTextField(); + foodLevelField.setPrefWidth(50); + foodLevelField.setAlignment(Pos.CENTER_RIGHT); + BorderPane.setAlignment(foodLevelField, Pos.CENTER_RIGHT); + foodLevelPane.setRight(foodLevelField); + + 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)); + } else { + foodLevelField.setDisable(true); + } + } + + BorderPane xpLevelPane = new BorderPane(); + { + Label label = new Label(i18n("world.info.player.xp_level")); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + xpLevelPane.setLeft(label); + + JFXTextField xpLevelField = new JFXTextField(); + xpLevelField.setPrefWidth(50); + xpLevelField.setAlignment(Pos.CENTER_RIGHT); + BorderPane.setAlignment(xpLevelField, Pos.CENTER_RIGHT); + xpLevelPane.setRight(xpLevelField); + + 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)); + } else { + xpLevelField.setDisable(true); + } + } + + playerInfo.getContent().setAll( + locationPane, lastDeathLocationPane, spawnPane, + playerGameTypePane, healthPane, foodLevelPane, xpLevelPane + ); + + rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.player")), playerInfo); + } + } + + private void saveLevelDat() { + LOG.info("Saving level.dat of world " + world.getWorldName()); + try { + this.world.writeLevelDat(levelDat); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to save level.dat of world " + world.getWorldName(), e); + } + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return stateProperty; + } + + private static final class Dimension { + 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); + } + } 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; + + Tag x = listTag.get(0); + Tag y = listTag.get(1); + Tag z = listTag.get(2); + + if (x instanceof DoubleTag && y instanceof DoubleTag && z instanceof DoubleTag) { + //noinspection MalformedFormatString + return this == OVERWORLD + ? String.format("(%.2f, %.2f, %.2f)", x.getValue(), y.getValue(), z.getValue()) + : String.format("%s (%.2f, %.2f, %.2f)", name, x.getValue(), y.getValue(), z.getValue()); + } + + return null; + } + + if (tag instanceof IntArrayTag) { + IntArrayTag intArrayTag = (IntArrayTag) tag; + + int x = intArrayTag.getValue(0); + int y = intArrayTag.getValue(1); + int z = intArrayTag.getValue(2); + + return this == OVERWORLD + ? String.format("(%d, %d, %d)", x, y, z) + : String.format("%s (%d, %d, %d)", name, x, y, z); + } + + return null; + } + + String formatPosition(int x, int y, int z) { + return this == OVERWORLD + ? String.format("(%d, %d, %d)", x, y, z) + : String.format("%s (%d, %d, %d)", name, x, y, z); + } + + String formatPosition(double x, double y, double z) { + return this == OVERWORLD + ? String.format("(%.2f, %.2f, %.2f)", x, y, z) + : String.format("%s (%.2f, %.2f, %.2f)", name, x, y, z); + } + } + + private enum Difficulty { + PEACEFUL, EASY, NORMAL, HARD; + + static final ObservableList items = FXCollections.observableList(Arrays.asList(values())); + + static Difficulty of(int d) { + return d >= 0 && d <= items.size() ? items.get(d) : null; + } + + @Override + public String toString() { + return i18n("world.info.difficulty." + name().toLowerCase(Locale.ROOT)); + } + } + + private enum GameType { + SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR; + + static final ObservableList items = FXCollections.observableList(Arrays.asList(values())); + + static GameType of(int d) { + return d >= 0 && d <= items.size() ? items.get(d) : null; + } + + @Override + public String toString() { + return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT)); + } + } +} 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 0dd2a5af5..6ddddfbe6 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 @@ -50,6 +50,8 @@ public class WorldListItem extends Control { title.set(parseColorEscapes(world.getWorldName())); subtitle.set(i18n("world.description", world.getFileName(), Locales.SIMPLE_DATE_FORMAT.get().format(new Date(world.getLastPlayed())), world.getGameVersion() == null ? i18n("message.unknown") : world.getGameVersion())); + + setOnMouseClicked(event -> showInfo()); } @Override @@ -99,4 +101,12 @@ public class WorldListItem extends Control { } Controllers.navigate(new DatapackListPage(world.getWorldName(), world.getFile())); } + + public void showInfo() { + try { + Controllers.navigate(new WorldInfoPage(world)); + } catch (Exception e) { + // TODO + } + } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index f30f3d4d3..0d22f0a8f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -937,6 +937,34 @@ world.import.already_exists=This world already exists. world.import.choose=Select the save archive you want to import world.import.failed=Unable to import this world\: %s world.import.invalid=Unable to parse the save. +world.info.title=World %s - Information +world.info.basic=Basic Information +world.info.allow_cheats=Allow Cheats +world.info.dimension.the_nether=The Nether +world.info.dimension.the_end=The End +world.info.difficulty=Difficulty +world.info.difficulty.peaceful=Peaceful +world.info.difficulty.easy=Easy +world.info.difficulty.normal=Normal +world.info.difficulty.hard=Hard +world.info.game_version=Game Version +world.info.last_played=Last Played +world.info.generate_features=Generate structures +world.info.player=Player Information +world.info.player.food_level=Hunger Level +world.info.player.game_type=Game Mode +world.info.player.game_type.adventure=Adventure +world.info.player.game_type.creative=Creative +world.info.player.game_type.spectator=Spectator +world.info.player.game_type.survival=Survival +world.info.player.health=Health +world.info.player.last_death_location=Last Death Location +world.info.player.location=Location +world.info.player.spawn=Spawn Location +world.info.player.xp_level=Experience Level +world.info.random_seed=Seed +world.info.time=Game Time +world.info.time.format=%s days world.manage=Worlds / Datapacks world.name=World Name world.name.enter=Enter the world name diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index f0517696a..2216b3615 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -777,6 +777,34 @@ world.import.already_exists=此世界已經存在 world.import.choose=選擇要匯入的存檔壓縮檔 world.import.failed=無法匯入此世界: %s world.import.invalid=無法識別的存檔壓縮包 +world.info.title=世界 %s - 世界資訊 +world.info.basic=基本資訊 +world.info.allow_cheats=允許作弊 +world.info.dimension.the_nether=下界 +world.info.dimension.the_end=末地 +world.info.difficulty=難度 +world.info.difficulty.peaceful=和平 +world.info.difficulty.easy=簡單 +world.info.difficulty.normal=普通 +world.info.difficulty.hard=困難 +world.info.game_version=遊戲版本 +world.info.last_played=上一次遊戲時間 +world.info.generate_features=生成建築 +world.info.player=玩家資訊 +world.info.player.food_level=饑餓值 +world.info.player.game_type=遊戲模式 +world.info.player.game_type.adventure=極限 +world.info.player.game_type.creative=創造 +world.info.player.game_type.spectator=旁觀 +world.info.player.game_type.survival=生存 +world.info.player.health=生命值 +world.info.player.last_death_location=上次死亡位置 +world.info.player.location=位置 +world.info.player.spawn=床/重生錨位置 +world.info.player.xp_level=經驗等級 +world.info.random_seed=種子 +world.info.time=遊戲內時間 +world.info.time.format=%s 天 world.game_version=遊戲版本 world.manage=世界/資料包 world.name=世界名稱 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 793fa1f0d..6859f4ed2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -778,6 +778,34 @@ world.import.already_exists=此世界已经存在 world.import.choose=选择要导入的存档压缩包 world.import.failed=无法导入此世界:%s world.import.invalid=无法识别该存档压缩包 +world.info.title=世界 %s - 世界信息 +world.info.basic=基本信息 +world.info.allow_cheats=允许作弊 +world.info.dimension.the_nether=下界 +world.info.dimension.the_end=末地 +world.info.difficulty=难度 +world.info.difficulty.peaceful=和平 +world.info.difficulty.easy=简单 +world.info.difficulty.normal=普通 +world.info.difficulty.hard=困难 +world.info.game_version=游戏版本 +world.info.last_played=上一次游戏时间 +world.info.generate_features=生成建筑 +world.info.player=玩家信息 +world.info.player.food_level=饥饿值 +world.info.player.game_type=游戏模式 +world.info.player.game_type.adventure=极限 +world.info.player.game_type.creative=创造 +world.info.player.game_type.spectator=旁观 +world.info.player.game_type.survival=生存 +world.info.player.health=生命值 +world.info.player.last_death_location=上次死亡位置 +world.info.player.location=位置 +world.info.player.spawn=床/重生锚位置 +world.info.player.xp_level=经验等级 +world.info.random_seed=种子 +world.info.time=游戏内时间 +world.info.time.format=%s 天 world.manage=世界/数据包 world.name=世界名称 world.name.enter=输入世界名称 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 c8ef2ebb2..f43b97ed6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -28,7 +28,6 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.Unzipper; import org.jackhuang.hmcl.util.io.Zipper; -import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -77,6 +76,10 @@ public class World { return worldName; } + public Path getLevelDatFile() { + return file.resolve("level.dat"); + } + public long getLastPlayed() { return lastPlayed; } @@ -142,14 +145,10 @@ public class World { throw new IOException("Not a valid world directory"); // Change the name recorded in level.dat - Path levelDat = file.resolve("level.dat"); - CompoundTag nbt = parseLevelDat(levelDat); + CompoundTag nbt = readLevelDat(); CompoundTag data = nbt.get("Data"); data.put(new StringTag("LevelName", newName)); - - try (OutputStream os = new GZIPOutputStream(Files.newOutputStream(levelDat))) { - NBTIO.writeTag(os, nbt); - } + writeLevelDat(nbt); // then change the folder's name Files.move(file, file.resolveSibling(newName)); @@ -203,8 +202,26 @@ public class World { } } + public CompoundTag readLevelDat() throws IOException { + if (!Files.isDirectory(file)) + throw new IOException("Not a valid world directory"); + + return parseLevelDat(getLevelDatFile()); + } + + public void writeLevelDat(CompoundTag nbt) throws IOException { + if (!Files.isDirectory(file)) + throw new IOException("Not a valid world directory"); + + FileUtils.saveSafely(getLevelDatFile(), os -> { + try (OutputStream gos = new GZIPOutputStream(os)) { + NBTIO.writeTag(gos, nbt); + } + }); + } + private static CompoundTag parseLevelDat(Path path) throws IOException { - try (InputStream is = new BufferedInputStream(new GZIPInputStream(Files.newInputStream(path)))) { + try (InputStream is = new GZIPInputStream(Files.newInputStream(path))) { Tag nbt = NBTIO.readTag(is); if (nbt instanceof CompoundTag) return (CompoundTag) nbt; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index 7c8bcad09..d7083af52 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -235,6 +235,15 @@ public final class Lang { } } + public static Double toDoubleOrNull(Object string) { + try { + if (string == null) return null; + return Double.parseDouble(string.toString()); + } catch (NumberFormatException e) { + return null; + } + } + /** * Find the first non-null reference in given list. * @param t nullable references list. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index bb0619d6c..73979d2f4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -19,11 +19,9 @@ package org.jackhuang.hmcl.util.io; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.function.ExceptionalConsumer; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; +import java.io.*; import java.lang.reflect.Method; import java.nio.charset.Charset; import java.nio.file.*; @@ -445,4 +443,21 @@ public final class FileUtils { Files.move(tmpFile, file, StandardCopyOption.REPLACE_EXISTING); } + + public static void saveSafely(Path file, ExceptionalConsumer action) throws IOException { + Path tmpFile = tmpSaveFile(file); + + try (OutputStream os = Files.newOutputStream(tmpFile, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)) { + action.accept(os); + } + + try { + if (Files.exists(file) && Files.getAttribute(file, "dos:hidden") == Boolean.TRUE) { + Files.setAttribute(tmpFile, "dos:hidden", true); + } + } catch (Throwable ignored) { + } + + Files.move(tmpFile, file, StandardCopyOption.REPLACE_EXISTING); + } }