From 27972b987e48f2c41c2202b71e24870aee19873c Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 29 Jan 2026 22:39:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=B8=96=E7=95=8C?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E4=B8=96=E7=95=8C=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20(#5215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> Co-authored-by: Glavo --- .../hmcl/ui/versions/DatapackListPage.java | 16 +- .../ui/versions/DatapackListPageSkin.java | 31 +- .../hmcl/ui/versions/WorldBackupsPage.java | 9 +- .../hmcl/ui/versions/WorldInfoPage.java | 347 +++++++++--------- .../hmcl/ui/versions/WorldListPage.java | 69 ++-- .../hmcl/ui/versions/WorldManagePage.java | 303 +++++++++------ .../hmcl/ui/versions/WorldManageUIUtils.java | 2 +- .../org/jackhuang/hmcl/util/ChunkBaseApp.java | 6 + .../resources/assets/lang/I18N.properties | 7 +- .../resources/assets/lang/I18N_ar.properties | 2 - .../resources/assets/lang/I18N_es.properties | 2 - .../resources/assets/lang/I18N_lzh.properties | 2 - .../resources/assets/lang/I18N_ru.properties | 2 - .../resources/assets/lang/I18N_uk.properties | 2 - .../resources/assets/lang/I18N_zh.properties | 7 +- .../assets/lang/I18N_zh_CN.properties | 7 +- .../java/org/jackhuang/hmcl/game/World.java | 56 +-- .../hmcl/launch/DefaultLauncher.java | 6 +- .../java/org/jackhuang/hmcl/util/Lang.java | 9 + 19 files changed, 509 insertions(+), 376 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index 49198822c..6165fa91d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.ui.versions; +import javafx.beans.property.BooleanProperty; import javafx.collections.ObservableList; import javafx.scene.control.Skin; import javafx.stage.FileChooser; @@ -42,20 +43,29 @@ import java.util.regex.Pattern; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class DatapackListPage extends ListPageBase { +public final class DatapackListPage extends ListPageBase implements WorldManagePage.WorldRefreshable { private final Path worldDir; private final Datapack datapack; + final BooleanProperty readOnly; public DatapackListPage(WorldManagePage worldManagePage) { this.worldDir = worldManagePage.getWorld().getFile(); datapack = new Datapack(worldDir.resolve("datapacks")); setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new)); + readOnly = worldManagePage.readOnlyProperty(); FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)), - mods -> mods.forEach(this::installSingleDatapack), this::refresh); + this::installMultiDatapack, this::refresh); refresh(); } + private void installMultiDatapack(List datapackPath) { + datapackPath.forEach(this::installSingleDatapack); + if (readOnly.get()) { + Controllers.showToast(i18n("datapack.reload.toast")); + } + } + private void installSingleDatapack(Path datapack) { try { this.datapack.installPack(datapack); @@ -83,7 +93,7 @@ public final class DatapackListPage extends ListPageBase res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage())); if (res != null) { - res.forEach(this::installSingleDatapack); + installMultiDatapack(res); } datapack.loadFromDir(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java index a6698bc2e..d77e714de 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java @@ -114,16 +114,23 @@ final class DatapackListPageSkin extends SkinBase { createToolbarButton2(i18n("search"), SVG.SEARCH, () -> isSearching.set(true)) ); + JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { + skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()); + }, null); + }); + JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () -> + skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())); + JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> + skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())); + removeButton.disableProperty().bind(getSkinnable().readOnly); + enableButton.disableProperty().bind(getSkinnable().readOnly); + disableButton.disableProperty().bind(getSkinnable().readOnly); + selectingToolbar.getChildren().addAll( - createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> { - Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { - skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()); - }, null); - }), - createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () -> - skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())), - createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> - skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())), + removeButton, + enableButton, + disableButton, createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size())),//reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> @@ -179,7 +186,7 @@ final class DatapackListPageSkin extends SkinBase { center.getStyleClass().add("large-spinner-pane"); center.loadingProperty().bind(skinnable.loadingProperty()); - listView.setCellFactory(x -> new DatapackInfoListCell(listView)); + listView.setCellFactory(x -> new DatapackInfoListCell(listView, getSkinnable().readOnly)); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); this.listView.setItems(filteredList); @@ -302,7 +309,7 @@ final class DatapackListPageSkin extends SkinBase { final TwoLineListItem content = new TwoLineListItem(); BooleanProperty booleanProperty; - DatapackInfoListCell(JFXListView listView) { + DatapackInfoListCell(JFXListView listView, BooleanProperty isReadOnlyProperty) { super(listView); HBox container = new HBox(8); @@ -312,6 +319,8 @@ final class DatapackListPageSkin extends SkinBase { content.setMouseTransparent(true); setSelectable(); + checkBox.disableProperty().bind(isReadOnlyProperty); + imageView.setFitWidth(32); imageView.setFitHeight(32); imageView.setPreserveRatio(true); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 02901d068..5a661eaa9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.versions; import com.jfoenix.controls.JFXButton; +import javafx.beans.property.BooleanProperty; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -61,18 +62,18 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ -public final class WorldBackupsPage extends ListPageBase { +public final class WorldBackupsPage extends ListPageBase implements WorldManagePage.WorldRefreshable { static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); private final World world; private final Path backupsDir; - private final boolean isReadOnly; + private final BooleanProperty readOnly; private final Pattern backupFileNamePattern; public WorldBackupsPage(WorldManagePage worldManagePage) { this.world = worldManagePage.getWorld(); this.backupsDir = worldManagePage.getBackupsDir(); - this.isReadOnly = worldManagePage.isReadOnly(); + this.readOnly = worldManagePage.readOnlyProperty(); this.backupFileNamePattern = Pattern.compile("(?[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?[0-9]+))?\\.zip"); refresh(); @@ -164,7 +165,7 @@ public final class WorldBackupsPage extends ListPageBase initializeToolbar(WorldBackupsPage skinnable) { JFXButton createBackup = createToolbarButton2(i18n("world.backup.create.new_one"), SVG.ARCHIVE, skinnable::createBackup); - createBackup.setDisable(isReadOnly); + createBackup.disableProperty().bind(getSkinnable().readOnly); return Arrays.asList( createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), 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 965450da4..59983bd17 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 @@ -21,6 +21,7 @@ import com.github.steveice10.opennbt.tag.builtin.*; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Pos; @@ -35,7 +36,6 @@ 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; @@ -43,13 +43,17 @@ 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.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.FileUtils; 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.Duration; import java.time.Instant; import java.util.Arrays; import java.util.Locale; @@ -62,8 +66,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ -public final class WorldInfoPage extends SpinnerPane { +public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable { private final WorldManagePage worldManagePage; + private boolean isReadOnly; private final World world; private CompoundTag levelDat; @@ -72,19 +77,7 @@ public final class WorldInfoPage extends SpinnerPane { public WorldInfoPage(WorldManagePage worldManagePage) { this.worldManagePage = worldManagePage; this.world = worldManagePage.getWorld(); - - this.setLoading(true); - Task.supplyAsync(this::loadWorldInfo) - .whenComplete(Schedulers.javafx(), ((result, exception) -> { - if (exception == null) { - this.levelDat = result; - updateControls(); - setLoading(false); - } else { - LOG.warning("Failed to load level.dat", exception); - setFailedReason(i18n("world.info.failed")); - } - })).start(); + refresh(); } private CompoundTag loadWorldInfo() throws IOException { @@ -96,7 +89,6 @@ public final class WorldInfoPage extends SpinnerPane { private void updateControls() { CompoundTag dataTag = levelDat.get("Data"); - CompoundTag worldGenSettings = dataTag.get("WorldGenSettings"); ScrollPane scrollPane = new ScrollPane(); scrollPane.setFitToHeight(true); @@ -110,7 +102,7 @@ public final class WorldInfoPage extends SpinnerPane { FXUtils.smoothScrolling(scrollPane); rootPane.getStyleClass().add("card-list"); - ComponentList basicInfo = new ComponentList(); + ComponentList worldInfo = new ComponentList(); { BorderPane worldNamePane = new BorderPane(); { @@ -118,14 +110,15 @@ public final class WorldInfoPage extends SpinnerPane { 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) { + if (dataTag.get("LevelName") instanceof StringTag worldNameTag) { + var worldName = new SimpleStringProperty(worldNameTag.getValue()); + FXUtils.bindString(worldNameField, worldName); + worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName); + worldName.addListener((observable, oldValue, newValue) -> { + if (StringUtils.isNotBlank(newValue)) { try { world.setWorldName(newValue); + worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName()))); } catch (Exception e) { LOG.warning("Failed to set world name", e); } @@ -139,22 +132,15 @@ public final class WorldInfoPage extends SpinnerPane { BorderPane gameVersionPane = new BorderPane(); { setLeftLabel(gameVersionPane, "world.info.game_version"); - Label gameVersionLabel = new Label(); - setRightTextLabel(gameVersionPane, gameVersionLabel, () -> world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString()); + setRightTextLabel(gameVersionPane, () -> world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString()); } BorderPane iconPane = new BorderPane(); { setLeftLabel(iconPane, "world.icon"); - Runnable onClickAction = () -> Controllers.confirm( - i18n("world.icon.change.tip"), i18n("world.icon.change"), MessageDialogPane.MessageType.INFO, - this::changeWorldIcon, - null - ); - - FXUtils.limitSize(iconImageView, 32, 32); { + FXUtils.limitSize(iconImageView, 32, 32); iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); } @@ -162,14 +148,20 @@ public final class WorldInfoPage extends SpinnerPane { JFXButton resetIconButton = new JFXButton(); { editIconButton.setGraphic(SVG.EDIT.createIcon(20)); - editIconButton.setDisable(worldManagePage.isReadOnly()); - FXUtils.onClicked(editIconButton, onClickAction); + editIconButton.setDisable(isReadOnly); + editIconButton.setOnAction(event -> Controllers.confirm( + I18n.i18n("world.icon.change.tip"), + I18n.i18n("world.icon.change"), + MessageDialogPane.MessageType.INFO, + this::changeWorldIcon, + null + )); 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); + resetIconButton.setDisable(isReadOnly); + resetIconButton.setOnAction(event -> this.clearWorldIcon()); FXUtils.installFastTooltip(resetIconButton, i18n("button.reset")); resetIconButton.getStyleClass().add("toggle-icon4"); } @@ -189,9 +181,7 @@ public final class WorldInfoPage extends SpinnerPane { StackPane visibilityButton = new StackPane(); { visibilityButton.setCursor(Cursor.HAND); - visibilityButton.setAlignment(Pos.BOTTOM_RIGHT); - FXUtils.setLimitWidth(visibilityButton, 12); - FXUtils.setLimitHeight(visibilityButton, 12); + visibilityButton.setAlignment(Pos.CENTER_RIGHT); FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get())); } @@ -219,23 +209,38 @@ public final class WorldInfoPage extends SpinnerPane { } } + BorderPane worldSpawnPoint = new BorderPane(); + { + setLeftLabel(worldSpawnPoint, "world.info.spawn"); + setRightTextLabel(worldSpawnPoint, () -> { + if (dataTag.get("spawn") instanceof CompoundTag spawnTag && spawnTag.get("pos") instanceof IntArrayTag posTag) { + return Dimension.of(spawnTag.get("dimension") instanceof StringTag dimensionTag + ? dimensionTag + : new StringTag("SpawnDimension", "minecraft:overworld")) + .formatPosition(posTag); + } else if (dataTag.get("SpawnX") instanceof IntTag intX + && dataTag.get("SpawnY") instanceof IntTag intY + && dataTag.get("SpawnZ") instanceof IntTag intZ) { + return Dimension.OVERWORLD.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()); + } else { + return ""; + } + }); + } + BorderPane lastPlayedPane = new BorderPane(); { setLeftLabel(lastPlayedPane, "world.info.last_played"); - Label lastPlayedLabel = new Label(); - setRightTextLabel(lastPlayedPane, lastPlayedLabel, () -> formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))); + setRightTextLabel(lastPlayedPane, () -> formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))); } BorderPane timePane = new BorderPane(); { setLeftLabel(timePane, "world.info.time"); - - Label timeLabel = new Label(); - 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); + setRightTextLabel(timePane, () -> { + if (dataTag.get("Time") instanceof LongTag timeTag) { + Duration duration = Duration.ofSeconds(timeTag.getValue() / 20); + return i18n("world.info.time.format", duration.toDays(), duration.toHoursPart(), duration.toMinutesPart()); } else { return ""; } @@ -245,19 +250,22 @@ public final class WorldInfoPage extends SpinnerPane { LineToggleButton allowCheatsButton = new LineToggleButton(); { allowCheatsButton.setTitle(i18n("world.info.allow_cheats")); - allowCheatsButton.setDisable(worldManagePage.isReadOnly()); - Tag tag = dataTag.get("allowCommands"); + allowCheatsButton.setDisable(isReadOnly); - checkTagAndSetListener(tag, allowCheatsButton); + bindTagAndToggleButton(dataTag.get("allowCommands"), allowCheatsButton); } LineToggleButton generateFeaturesButton = new LineToggleButton(); { generateFeaturesButton.setTitle(i18n("world.info.generate_features")); - generateFeaturesButton.setDisable(worldManagePage.isReadOnly()); - Tag tag = worldGenSettings != null ? worldGenSettings.get("generate_features") : dataTag.get("MapFeatures"); + generateFeaturesButton.setDisable(isReadOnly); - checkTagAndSetListener(tag, generateFeaturesButton); + // generate_features was valid after 20w20a and MapFeatures was before that + if (dataTag.get("WorldGenSettings") instanceof CompoundTag worldGenSettings) { + bindTagAndToggleButton(worldGenSettings.get("generate_features"), generateFeaturesButton); + } else { + bindTagAndToggleButton(dataTag.get("MapFeatures"), generateFeaturesButton); + } } LineSelectButton difficultyButton = new LineSelectButton<>(); @@ -266,14 +274,13 @@ public final class WorldInfoPage extends SpinnerPane { difficultyButton.setDisable(worldManagePage.isReadOnly()); difficultyButton.setItems(Difficulty.items); - Tag tag = dataTag.get("Difficulty"); - if (tag instanceof ByteTag byteTag) { - Difficulty difficulty = Difficulty.of(byteTag.getValue()); + if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag) { + Difficulty difficulty = Difficulty.of(difficultyTag.getValue()); if (difficulty != null) { difficultyButton.setValue(difficulty); difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> { if (newValue != null) { - byteTag.setValue((byte) newValue.ordinal()); + difficultyTag.setValue((byte) newValue.ordinal()); saveLevelDat(); } }); @@ -288,33 +295,28 @@ public final class WorldInfoPage extends SpinnerPane { LineToggleButton difficultyLockPane = new LineToggleButton(); { difficultyLockPane.setTitle(i18n("world.info.difficulty_lock")); - difficultyLockPane.setDisable(worldManagePage.isReadOnly()); + difficultyLockPane.setDisable(isReadOnly); - Tag tag = dataTag.get("DifficultyLocked"); - checkTagAndSetListener(tag, difficultyLockPane); + bindTagAndToggleButton(dataTag.get("DifficultyLocked"), difficultyLockPane); } - basicInfo.getContent().setAll( - worldNamePane, gameVersionPane, iconPane, seedPane, lastPlayedPane, timePane, + worldInfo.getContent().setAll( + worldNamePane, gameVersionPane, iconPane, seedPane, worldSpawnPoint, lastPlayedPane, timePane, allowCheatsButton, generateFeaturesButton, difficultyButton, difficultyLockPane); - rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.basic")), basicInfo); + rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info")), worldInfo); } - Tag playerTag = dataTag.get("Player"); - if (playerTag instanceof CompoundTag player) { + if (dataTag.get("Player") instanceof CompoundTag playerTag) { ComponentList playerInfo = new ComponentList(); BorderPane locationPane = new BorderPane(); { setLeftLabel(locationPane, "world.info.player.location"); - Label locationLabel = new Label(); - 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; + setRightTextLabel(locationPane, () -> { + Dimension dimension = Dimension.of(playerTag.get("Dimension")); + if (dimension != null && playerTag.get("Pos") instanceof ListTag posTag) { + return dimension.formatPosition(posTag); } return ""; }); @@ -323,15 +325,12 @@ public final class WorldInfoPage extends SpinnerPane { BorderPane lastDeathLocationPane = new BorderPane(); { setLeftLabel(lastDeathLocationPane, "world.info.player.last_death_location"); - Label lastDeathLocationLabel = new Label(); - 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; + setRightTextLabel(lastDeathLocationPane, () -> { + // Valid after 22w14a; prior to this version, the game did not record the last death location data. + if (playerTag.get("LastDeathLocation") instanceof CompoundTag LastDeathLocationTag) { + Dimension dimension = Dimension.of(LastDeathLocationTag.get("dimension")); + if (dimension != null && LastDeathLocationTag.get("pos") instanceof IntArrayTag posTag) { + return dimension.formatPosition(posTag); } } return ""; @@ -342,116 +341,84 @@ public final class WorldInfoPage extends SpinnerPane { BorderPane spawnPane = new BorderPane(); { setLeftLabel(spawnPane, "world.info.player.spawn"); - Label spawnLabel = new Label(); - setRightTextLabel(spawnPane, spawnLabel, () -> { + setRightTextLabel(spawnPane, () -> { - 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 + if (playerTag.get("respawn") instanceof CompoundTag respawnTag + && respawnTag.get("dimension") instanceof StringTag dimensionTag + && respawnTag.get("pos") instanceof IntArrayTag intArrayTag + && intArrayTag.length() >= 3) { // Valid after 25w07a + return Dimension.of(dimensionTag).formatPosition(intArrayTag); + } else if (playerTag.get("SpawnX") instanceof IntTag intX + && playerTag.get("SpawnY") instanceof IntTag intY + && playerTag.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 (playerTag.get("SpawnDimension") instanceof StringTag dimensionTag ? Dimension.of(dimensionTag) : Dimension.OVERWORLD) + .formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()); } return ""; }); } - LineSelectButton playerGameTypeButton = new LineSelectButton<>(); + LineSelectButton playerGameTypePane = new LineSelectButton<>(); { - playerGameTypeButton.setTitle(i18n("world.info.player.game_type")); - playerGameTypeButton.setDisable(worldManagePage.isReadOnly()); - playerGameTypeButton.setItems(GameType.items); + playerGameTypePane.setTitle(i18n("world.info.player.game_type")); + playerGameTypePane.setDisable(worldManagePage.isReadOnly()); + playerGameTypePane.setItems(GameType.items); - Tag tag = player.get("playerGameType"); - Tag hardcoreTag = dataTag.get("hardcore"); - boolean isHardcore = hardcoreTag instanceof ByteTag && ((ByteTag) hardcoreTag).getValue() == 1; - - if (tag instanceof IntTag intTag) { - GameType gameType = GameType.of(intTag.getValue(), isHardcore); + if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag + && dataTag.get("hardcore") instanceof ByteTag hardcoreTag) { + boolean isHardcore = hardcoreTag.getValue() == 1; + GameType gameType = GameType.of(playerGameTypeTag.getValue(), isHardcore); if (gameType != null) { - playerGameTypeButton.setValue(gameType); - playerGameTypeButton.valueProperty().addListener((o, oldValue, newValue) -> { + playerGameTypePane.setValue(gameType); + playerGameTypePane.valueProperty().addListener((o, oldValue, newValue) -> { if (newValue != null) { if (newValue == GameType.HARDCORE) { - intTag.setValue(0); // survival (hardcore worlds are survival+hardcore flag) - if (hardcoreTag instanceof ByteTag) { - ((ByteTag) hardcoreTag).setValue((byte) 1); - } + playerGameTypeTag.setValue(0); // survival (hardcore worlds are survival+hardcore flag) + hardcoreTag.setValue((byte) 1); } else { - intTag.setValue(newValue.ordinal()); - if (hardcoreTag instanceof ByteTag) { - ((ByteTag) hardcoreTag).setValue((byte) 0); - } + playerGameTypeTag.setValue(newValue.ordinal()); + hardcoreTag.setValue((byte) 0); } saveLevelDat(); } }); } else { - playerGameTypeButton.setDisable(true); + playerGameTypePane.setDisable(true); } } else { - playerGameTypeButton.setDisable(true); + playerGameTypePane.setDisable(true); } } BorderPane healthPane = new BorderPane(); { setLeftLabel(healthPane, "world.info.player.health"); - JFXTextField healthField = new JFXTextField(); - setRightTextField(healthPane, healthField, 50); - - Tag tag = player.get("Health"); - if (tag instanceof FloatTag floatTag) { - setTagAndTextField(floatTag, healthField); - } else { - healthField.setDisable(true); - } + setRightTextField(healthPane, 50, playerTag.get("Health")); } BorderPane foodLevelPane = new BorderPane(); { setLeftLabel(foodLevelPane, "world.info.player.food_level"); - JFXTextField foodLevelField = new JFXTextField(); - setRightTextField(foodLevelPane, foodLevelField, 50); + setRightTextField(foodLevelPane, 50, playerTag.get("foodLevel")); + } - Tag tag = player.get("foodLevel"); - if (tag instanceof IntTag intTag) { - setTagAndTextField(intTag, foodLevelField); - } else { - foodLevelField.setDisable(true); - } + BorderPane foodSaturationPane = new BorderPane(); + { + setLeftLabel(foodSaturationPane, "world.info.player.food_saturation_level"); + setRightTextField(foodSaturationPane, 50, playerTag.get("foodSaturationLevel")); } BorderPane xpLevelPane = new BorderPane(); { setLeftLabel(xpLevelPane, "world.info.player.xp_level"); - JFXTextField xpLevelField = new JFXTextField(); - setRightTextField(xpLevelPane, xpLevelField, 50); - - Tag tag = player.get("XpLevel"); - if (tag instanceof IntTag intTag) { - setTagAndTextField(intTag, xpLevelField); - } else { - xpLevelField.setDisable(true); - } + setRightTextField(xpLevelPane, 50, playerTag.get("XpLevel")); } playerInfo.getContent().setAll( - locationPane, lastDeathLocationPane, spawnPane, - playerGameTypeButton, healthPane, foodLevelPane, xpLevelPane + locationPane, lastDeathLocationPane, spawnPane, playerGameTypePane, + healthPane, foodLevelPane, foodSaturationPane, xpLevelPane ); rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.player")), playerInfo); @@ -464,14 +431,27 @@ public final class WorldInfoPage extends SpinnerPane { borderPane.setLeft(label); } + private void setRightTextField(BorderPane borderPane, int perfWidth, Tag tag) { + JFXTextField textField = new JFXTextField(); + setRightTextField(borderPane, textField, perfWidth); + if (tag instanceof IntTag intTag) { + bindTagAndTextField(intTag, textField); + } else if (tag instanceof FloatTag floatTag) { + bindTagAndTextField(floatTag, textField); + } else { + textField.setDisable(true); + } + } + private void setRightTextField(BorderPane borderPane, JFXTextField textField, int perfWidth) { - textField.setDisable(worldManagePage.isReadOnly()); + textField.setDisable(isReadOnly); textField.setPrefWidth(perfWidth); - textField.setAlignment(Pos.CENTER_RIGHT); + BorderPane.setAlignment(textField, Pos.CENTER_RIGHT); borderPane.setRight(textField); } - private void setRightTextLabel(BorderPane borderPane, Label label, Callable setNameCall) { + private void setRightTextLabel(BorderPane borderPane, Callable setNameCall) { + Label label = new Label(); FXUtils.copyOnDoubleClick(label); BorderPane.setAlignment(label, Pos.CENTER_RIGHT); try { @@ -482,7 +462,7 @@ public final class WorldInfoPage extends SpinnerPane { borderPane.setRight(label); } - private void checkTagAndSetListener(Tag tag, LineToggleButton toggleButton) { + private void bindTagAndToggleButton(Tag tag, LineToggleButton toggleButton) { if (tag instanceof ByteTag byteTag) { byte value = byteTag.getValue(); if (value == 0 || value == 1) { @@ -504,14 +484,17 @@ public final class WorldInfoPage extends SpinnerPane { } } - private void setTagAndTextField(IntTag intTag, JFXTextField jfxTextField) { - jfxTextField.setText(String.valueOf(intTag.getValue())); + private void bindTagAndTextField(IntTag intTag, JFXTextField jfxTextField) { + jfxTextField.setText(intTag.getValue().toString()); jfxTextField.textProperty().addListener((o, oldValue, newValue) -> { if (newValue != null) { try { - intTag.setValue(Integer.parseInt(newValue)); - saveLevelDat(); + Integer integer = Lang.toIntOrNull(newValue); + if (integer != null) { + intTag.setValue(integer); + saveLevelDat(); + } } catch (Exception e) { jfxTextField.setText(oldValue); LOG.warning("Exception happened when saving level.dat", e); @@ -522,14 +505,17 @@ public final class WorldInfoPage extends SpinnerPane { jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true)); } - private void setTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) { - jfxTextField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue())); + private void bindTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) { + jfxTextField.setText(new DecimalFormat("0.#").format(floatTag.getValue())); jfxTextField.textProperty().addListener((o, oldValue, newValue) -> { if (newValue != null) { try { - floatTag.setValue(Float.parseFloat(newValue)); - saveLevelDat(); + Float floatValue = Lang.toFloatOrNull(newValue); + if (floatValue != null) { + floatTag.setValue(floatValue); + saveLevelDat(); + } } catch (Exception e) { jfxTextField.setText(oldValue); LOG.warning("Exception happened when saving level.dat", e); @@ -549,6 +535,23 @@ public final class WorldInfoPage extends SpinnerPane { } } + @Override + public void refresh() { + this.isReadOnly = worldManagePage.isReadOnly(); + this.setLoading(true); + Task.supplyAsync(this::loadWorldInfo) + .whenComplete(Schedulers.javafx(), ((result, exception) -> { + if (exception == null) { + this.levelDat = result; + updateControls(); + setLoading(false); + } else { + LOG.warning("Failed to load level.dat", exception); + setFailedReason(i18n("world.info.failed")); + } + })).start(); + } + 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")); @@ -558,8 +561,8 @@ public final class WorldInfoPage extends SpinnerPane { if (tag instanceof IntTag intTag) { return switch (intTag.getValue()) { case 0 -> OVERWORLD; - case 1 -> THE_NETHER; - case 2 -> THE_END; + case -1 -> THE_NETHER; + case 1 -> THE_END; default -> null; }; } else if (tag instanceof StringTag stringTag) { @@ -655,12 +658,12 @@ public final class WorldInfoPage extends SpinnerPane { fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png")); fileChooser.setInitialFileName("icon.png"); - File file = fileChooser.showOpenDialog(Controllers.getStage()); - if (file == null) return; + Path iconPath = FileUtils.toPath(fileChooser.showOpenDialog(Controllers.getStage())); + if (iconPath == null) return; Image image; try { - image = FXUtils.loadImage(file.toPath()); + image = FXUtils.loadImage(iconPath); } 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); @@ -668,16 +671,16 @@ public final class WorldInfoPage extends SpinnerPane { } if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) { Path output = world.getFile().resolve("icon.png"); - saveImage(image, output); + saveWorldIcon(iconPath, 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) { + private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) { Image oldImage = iconImageView.getImage(); try { - PNGJavaFXUtils.writeImage(image, path); + FileUtils.copyFile(sourcePath, targetPath); iconImageView.setImage(image); Controllers.showToast(i18n("world.icon.change.succeed.toast")); } catch (IOException e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index dc251c1fe..212ea67b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -53,7 +53,6 @@ import java.nio.file.Path; import java.time.Instant; import java.util.Arrays; import java.util.List; -import java.util.stream.Stream; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; @@ -65,10 +64,9 @@ public final class WorldListPage extends ListPageBase implements VersionP private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); private Path savesDir; - private Path backupsDir; private List worlds; private Profile profile; - private String id; + private String instanceId; private int refreshCount = 0; @@ -88,9 +86,8 @@ public final class WorldListPage extends ListPageBase implements VersionP @Override public void loadVersion(Profile profile, String id) { this.profile = profile; - this.id = id; + this.instanceId = id; this.savesDir = profile.getRepository().getSavesDirectory(id); - this.backupsDir = profile.getRepository().getBackupsDirectory(id); refresh(); } @@ -100,7 +97,7 @@ public final class WorldListPage extends ListPageBase implements VersionP } else if (showAll.get()) { getItems().setAll(worlds); } else { - GameVersionNumber gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null); + GameVersionNumber gameVersion = profile.getRepository().getGameVersion(instanceId).map(GameVersionNumber::asGameVersion).orElse(null); getItems().setAll(worlds.stream() .filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) .toList()); @@ -108,7 +105,7 @@ public final class WorldListPage extends ListPageBase implements VersionP } public void refresh() { - if (profile == null || id == null) + if (profile == null || instanceId == null) return; int currentRefresh = ++refreshCount; @@ -116,10 +113,8 @@ public final class WorldListPage extends ListPageBase implements VersionP setLoading(true); Task.supplyAsync(Schedulers.io(), () -> { // Ensure the game version number is parsed - profile.getRepository().getGameVersion(id); - try (Stream stream = World.getWorlds(savesDir)) { - return stream.toList(); - } + profile.getRepository().getGameVersion(instanceId); + return World.getWorlds(savesDir); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (refreshCount != currentRefresh) { // A newer refresh task is running, discard this result @@ -177,7 +172,7 @@ public final class WorldListPage extends ListPageBase implements VersionP } private void showManagePage(World world) { - Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id)); + Controllers.navigate(new WorldManagePage(world, profile, instanceId)); } public void export(World world) { @@ -197,11 +192,11 @@ public final class WorldListPage extends ListPageBase implements VersionP } public void launch(World world) { - Versions.launchAndEnterWorld(profile, id, world.getFileName()); + Versions.launchAndEnterWorld(profile, instanceId, world.getFileName()); } public void generateLaunchScript(World world) { - Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName()); + Versions.generateLaunchScriptForQuickEnterWorld(profile, instanceId, world.getFileName()); } public BooleanProperty showAllProperty() { @@ -216,14 +211,15 @@ public final class WorldListPage extends ListPageBase implements VersionP @Override protected List initializeToolbar(WorldListPage skinnable) { - JFXCheckBox chkShowAll = new JFXCheckBox(); - chkShowAll.setText(i18n("world.show_all")); + JFXCheckBox chkShowAll = new JFXCheckBox(i18n("world.show_all")); chkShowAll.selectedProperty().bindBidirectional(skinnable.showAllProperty()); - return Arrays.asList(chkShowAll, + return Arrays.asList( + chkShowAll, createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add), - createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download)); + createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download) + ); } @Override @@ -240,6 +236,7 @@ public final class WorldListPage extends ListPageBase implements VersionP private final ImageView imageView; private final Tooltip leftTooltip; private final TwoLineListItem content; + private final JFXButton btnLaunch; public WorldListCell(WorldListPage page) { this.page = page; @@ -271,6 +268,17 @@ public final class WorldListPage extends ListPageBase implements VersionP root.setRight(right); right.setAlignment(Pos.CENTER_RIGHT); + btnLaunch = new JFXButton(); + right.getChildren().add(btnLaunch); + btnLaunch.getStyleClass().add("toggle-icon4"); + btnLaunch.setGraphic(SVG.ROCKET_LAUNCH.createIcon()); + FXUtils.installFastTooltip(btnLaunch, i18n("version.launch")); + btnLaunch.setOnAction(event -> { + World world = getItem(); + if (world != null) + page.launch(world); + }); + JFXButton btnMore = new JFXButton(); right.getChildren().add(btnMore); btnMore.getStyleClass().add("toggle-icon4"); @@ -317,8 +325,12 @@ public final class WorldListPage extends ListPageBase implements VersionP if (world.getGameVersion() != null) content.addTag(I18n.getDisplayVersion(world.getGameVersion())); - if (world.isLocked()) + if (world.isLocked()) { content.addTag(i18n("world.locked")); + btnLaunch.setDisable(true); + } else { + btnLaunch.setDisable(false); + } content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())))); @@ -329,13 +341,15 @@ public final class WorldListPage extends ListPageBase implements VersionP // Popup Menu public void showPopupMenu(World world, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { + boolean worldLocked = world.isLocked(); + PopupMenu popupMenu = new PopupMenu(); JFXPopup popup = new JFXPopup(popupMenu); - if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { + if (world.supportQuickPlay()) { IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), () -> page.launch(world), popup); - launchItem.setDisable(world.isLocked()); + launchItem.setDisable(worldLocked); popupMenu.getContent().add(launchItem); popupMenu.getContent().addAll( @@ -354,18 +368,19 @@ public final class WorldListPage extends ListPageBase implements VersionP new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup) ); - 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)); + if (ChunkBaseApp.supportEndCity(world)) { + popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup)); } } IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup); + exportMenuItem.setDisable(worldLocked); + IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> page.delete(world), popup); + deleteMenuItem.setDisable(worldLocked); + IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup); - boolean worldLocked = world.isLocked(); - Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem) - .forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked)); + duplicateMenuItem.setDisable(worldLocked); popupMenu.getContent().addAll( new MenuSeparator(), 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 2569f1042..902116af0 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 @@ -19,9 +19,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; +import javafx.beans.property.*; import javafx.geometry.Insets; import javafx.scene.layout.BorderPane; import javafx.scene.layout.Priority; @@ -37,6 +35,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 org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.channels.FileChannel; @@ -50,143 +49,92 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; */ public final class WorldManagePage extends DecoratorAnimatedPage implements DecoratorPage { - private final ObjectProperty state; private final World world; private final Path backupsDir; private final Profile profile; - private final String id; + private final String versionId; + private FileChannel sessionLockChannel; - private boolean loadFailed = false; + private final ObjectProperty state; + private boolean isFirstNavigation = true; + private final BooleanProperty refreshable = new SimpleBooleanProperty(true); + private final BooleanProperty readOnly = new SimpleBooleanProperty(false); - private final TabHeader header; + private final TransitionPane transitionPane = new TransitionPane(); + private final TabHeader header = new TabHeader(transitionPane); private final TabHeader.Tab worldInfoTab = new TabHeader.Tab<>("worldInfoPage"); private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); private final TabHeader.Tab datapackTab = new TabHeader.Tab<>("datapackListPage"); - private final TransitionPane transitionPane = new TransitionPane(); - - private FileChannel sessionLockChannel; - - public WorldManagePage(World world, Path backupsDir, Profile profile, String id) { + public WorldManagePage(World world, Profile profile, String versionId) { this.world = world; - this.backupsDir = backupsDir; + this.backupsDir = profile.getRepository().getBackupsDirectory(versionId); this.profile = profile; - this.id = id; + this.versionId = versionId; + + updateSessionLockChannel(); - sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); try { - world.reloadLevelDat(); + this.world.reloadLevelDat(); } catch (IOException e) { - LOG.warning("Can not load world level.dat of world: " + world.getFile(), e); - loadFailed = true; + LOG.warning("Can not load world level.dat of world: " + this.world.getFile(), e); + this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail()); } - this.worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); - this.worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); - this.datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); + worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); + worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); + datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); - this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())))); - this.header = new TabHeader(transitionPane, worldInfoTab, worldBackupsTab); - header.select(worldInfoTab); - - setCenter(transitionPane); - - BorderPane left = new BorderPane(); - FXUtils.setLimitWidth(left, 200); - VBox.setVgrow(left, Priority.ALWAYS); - setLeft(left); - - AdvancedListBox sideBar = new AdvancedListBox() - .addNavigationDrawerTab(header, worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) - .addNavigationDrawerTab(header, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); - - if (world.getGameVersion() != null && // old game will not write game version to level.dat - world.getGameVersion().isAtLeast("1.13", "17w43a")) { - header.getTabs().add(datapackTab); - sideBar.addNavigationDrawerTab(header, datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); - } - - left.setTop(sideBar); - - AdvancedListBox toolbar = new AdvancedListBox(); - - if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { - toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, advancedListItem -> advancedListItem.setDisable(isReadOnly())); - } - - if (ChunkBaseApp.isSupported(world)) { - PopupMenu chunkBasePopupMenu = new PopupMenu(); - JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu); - - 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) { - 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 -> - 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); - - if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { - managePopupMenu.getContent().addAll( - new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), this::launch, managePopup), - new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), this::generateLaunchScript, managePopup), - new MenuSeparator() - ); - } - - 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.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); 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); - }); + @Override + protected @NotNull Skin createDefaultSkin() { + return new Skin(this); + } + + @Override + public void refresh() { + updateSessionLockChannel(); + try { + world.reloadLevelDat(); + } catch (IOException e) { + LOG.warning("Can not load world level.dat of world: " + world.getFile(), e); + closePageForLoadingFail(); return; } + + for (var tab : header.getTabs()) { + if (tab.getNode() instanceof WorldRefreshable r) { + r.refresh(); + } + } + } + + private void closePageForLoadingFail() { + Platform.runLater(() -> { + fireEvent(new PageCloseEvent()); + Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR); + }); + } + + private void updateSessionLockChannel() { if (sessionLockChannel == null || !sessionLockChannel.isOpen()) { sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); + readOnly.set(sessionLockChannel == null); } } + private void onNavigated(Navigator.NavigationEvent event) { + if (isFirstNavigation) + isFirstNavigation = false; + else + refresh(); + } + public void onExited(Navigator.NavigationEvent event) { try { WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); @@ -194,11 +142,24 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco } } + public void launch() { + fireEvent(new PageCloseEvent()); + Versions.launchAndEnterWorld(profile, versionId, world.getFileName()); + } + + public void generateLaunchScript() { + Versions.generateLaunchScriptForQuickEnterWorld(profile, versionId, world.getFileName()); + } + @Override public ReadOnlyObjectProperty stateProperty() { return state; } + public void setTitle(String title) { + this.state.set(new DecoratorPage.State(title, null, true, true, true)); + } + public World getWorld() { return world; } @@ -208,15 +169,123 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco } public boolean isReadOnly() { - return sessionLockChannel == null; + return readOnly.get(); } - public void launch() { - fireEvent(new PageCloseEvent()); - Versions.launchAndEnterWorld(profile, id, world.getFileName()); + public BooleanProperty readOnlyProperty() { + return readOnly; } - public void generateLaunchScript() { - Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName()); + @Override + public BooleanProperty refreshableProperty() { + return refreshable; + } + + public static class Skin extends DecoratorAnimatedPageSkin { + + protected Skin(WorldManagePage control) { + super(control); + + setCenter(control.transitionPane); + setLeft(getSidebar()); + } + + private BorderPane getSidebar() { + BorderPane sidebar = new BorderPane(); + { + FXUtils.setLimitWidth(sidebar, 200); + VBox.setVgrow(sidebar, Priority.ALWAYS); + } + + sidebar.setTop(getTabBar()); + sidebar.setBottom(getToolBar()); + + return sidebar; + } + + private AdvancedListBox getTabBar() { + AdvancedListBox tabBar = new AdvancedListBox(); + { + getSkinnable().header.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab); + getSkinnable().header.select(getSkinnable().worldInfoTab); + + tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) + .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); + + if (getSkinnable().world.supportDatapacks()) { + getSkinnable().header.getTabs().add(getSkinnable().datapackTab); + tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); + } + } + + return tabBar; + } + + private AdvancedListBox getToolBar() { + AdvancedListBox toolbar = new AdvancedListBox(); + BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); + { + if (getSkinnable().world.supportQuickPlay()) { + toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty())); + } + + if (ChunkBaseApp.isSupported(getSkinnable().world)) { + PopupMenu chunkBasePopupMenu = new PopupMenu(); + JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu); + + chunkBasePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(getSkinnable().world), chunkBasePopup), + new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(getSkinnable().world), chunkBasePopup), + new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(getSkinnable().world), chunkBasePopup) + ); + + if (ChunkBaseApp.supportEndCity(getSkinnable().world)) { + chunkBasePopupMenu.getContent().add( + new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(getSkinnable().world), chunkBasePopup)); + } + + toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem -> + chunkBaseMenuItem.setOnAction(e -> + chunkBasePopup.show(chunkBaseMenuItem, + JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, + chunkBaseMenuItem.getWidth(), 0))); + } + + toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(getSkinnable().world.getFile())); + + { + PopupMenu managePopupMenu = new PopupMenu(); + JFXPopup managePopup = new JFXPopup(managePopupMenu); + + if (getSkinnable().world.supportQuickPlay()) { + managePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), () -> getSkinnable().launch(), managePopup), + new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> getSkinnable().generateLaunchScript(), managePopup), + new MenuSeparator() + ); + } + + managePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(getSkinnable().world, getSkinnable().sessionLockChannel), managePopup), + new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> WorldManageUIUtils.delete(getSkinnable().world, () -> getSkinnable().fireEvent(new PageCloseEvent()), getSkinnable().sessionLockChannel), managePopup), + new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(getSkinnable().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.disableProperty().bind(getSkinnable().readOnlyProperty()); + }); + } + } + return toolbar; + } + } + + public interface WorldRefreshable { + void refresh(); } } 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 index d91a269b9..2fd9176db 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -144,7 +144,7 @@ public final class WorldManageUIUtils { FileChannel lock = world.lock(); LOG.info("Acquired lock on world " + world.getFileName()); return lock; - } catch (IOException ignored) { + } catch (WorldLockedException ignored) { return null; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java index 8556161bd..dca569501 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java @@ -28,6 +28,7 @@ public final class ChunkBaseApp { private static final String CHUNK_BASE_URL = "https://www.chunkbase.com"; private static final GameVersionNumber MIN_GAME_VERSION = GameVersionNumber.asGameVersion("1.7"); + private static final GameVersionNumber MIN_END_CITY_VERSION = GameVersionNumber.asGameVersion("1.13"); private static final String[] SEED_MAP_GAME_VERSIONS = { "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21", "1.20", @@ -52,6 +53,11 @@ public final class ChunkBaseApp { world.getGameVersion().compareTo(MIN_GAME_VERSION) >= 0; } + public static boolean supportEndCity(@NotNull World world) { + return world.getSeed() != null && world.getGameVersion() != null && + world.getGameVersion().compareTo(MIN_END_CITY_VERSION) >= 0; + } + public static ChunkBaseApp newBuilder(String app, long seed) { return new ChunkBaseApp(new StringBuilder(CHUNK_BASE_URL).append("/apps/").append(app).append("#seed=").append(seed)); } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 645b2f982..579fdfe5e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1130,6 +1130,7 @@ datapack=Datapacks datapack.add=Install Datapack datapack.choose_datapack=Choose datapack to import datapack.extension=Datapack +datapack.reload.toast=Minecraft is running, please use the /reload command to reload the data pack datapack.title=World [%s] - Datapacks web.failed=Failed to load page @@ -1198,6 +1199,7 @@ 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.food_saturation_level=Saturation world.info.player.game_type=Game Mode world.info.player.game_type.adventure=Adventure world.info.player.game_type.creative=Creative @@ -1210,8 +1212,9 @@ 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.info.spawn=World Spawn Location +world.info.time=Played Time +world.info.time.format=%dd %dh %dm 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. diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 49948220e..cac28f175 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1153,8 +1153,6 @@ 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.locked=قيد الاستخدام world.locked.failed=العالم قيد الاستخدام حاليًا. يرجى إغلاق اللعبة والمحاولة مرة أخرى. world.manage=العوالم diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index b8e7170d2..3caa0fca5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1162,8 +1162,6 @@ world.info.player.location=Ubicación world.info.player.spawn=Ubicación de desove world.info.player.xp_level=Nivel de experiencia world.info.random_seed=Semilla -world.info.time=Tiempo de juego -world.info.time.format=%s días world.locked=En uso world.locked.failed=El mundo está actualmente en uso. Por favor, cierra el juego e inténtalo de nuevo. world.manage=Mundos diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 0fdc1311c..726347fdd 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -951,8 +951,6 @@ 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.locked=見用 world.manage=司生界 world.manage.button=司生界 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 70a37f6dc..bfac684d4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1154,8 +1154,6 @@ 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.locked=В эксплуатации world.locked.failed=В настоящее время мир находится в эксплуатации. Закройте игру и попробуйте снова. world.manage=Миры diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 19bc15cca..9e9a9aeb7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1100,8 +1100,6 @@ 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.locked=Використовується world.locked.failed=Світ наразі використовується. Закрийте гру та спробуйте знову. world.manage=Світи diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index f60be61cf..c1166f1b4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -919,6 +919,7 @@ datapack=資料包 datapack.add=新增資料包 datapack.choose_datapack=選取要匯入的資料包壓縮檔 datapack.extension=資料包 +datapack.reload.toast=Minecraft 正在執行,請使用 /reload 指令重新載入資料包 datapack.title=世界 [%s] - 資料包 web.failed=載入頁面失敗 @@ -986,6 +987,7 @@ world.info.last_played=上一次遊戲時間 world.info.generate_features=生成建築 world.info.player=玩家資訊 world.info.player.food_level=饑餓值 +world.info.player.food_saturation_level=飽食度 world.info.player.game_type=遊戲模式 world.info.player.game_type.adventure=冒險 world.info.player.game_type.creative=創造 @@ -998,8 +1000,9 @@ 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.info.spawn=世界重生點 +world.info.time=遊戲時間 +world.info.time.format=%d 天 %d 小時 %d 分鐘 world.load.fail=世界載入失敗 world.locked=使用中 world.locked.failed=該世界正在使用中,請關閉遊戲後重試。 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 3dfd5c652..d47299447 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -924,6 +924,7 @@ datapack=数据包 datapack.add=添加数据包 datapack.choose_datapack=选择要导入的数据包压缩包 datapack.extension=数据包 +datapack.reload.toast=Minecraft 正在运行,请使用 /reload 命令重新加载数据包 datapack.title=世界 [%s] - 数据包 web.failed=加载页面失败 @@ -992,6 +993,7 @@ world.info.last_played=上一次游戏时间 world.info.generate_features=生成建筑 world.info.player=玩家信息 world.info.player.food_level=饥饿值 +world.info.player.food_saturation_level=饱和度 world.info.player.game_type=游戏模式 world.info.player.game_type.adventure=冒险 world.info.player.game_type.creative=创造 @@ -1004,8 +1006,9 @@ 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.info.spawn=世界出生点 +world.info.time=游戏时长 +world.info.time.format=%d 天 %d 小时 %d 分钟 world.load.fail=世界加载失败 world.locked=使用中 world.locked.failed=该世界正在使用中,请关闭游戏后重试。 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 24943caf0..8e3f6fc38 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -34,7 +34,6 @@ import java.nio.channels.OverlappingFileLockException; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.List; -import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -47,7 +46,6 @@ public final class World { private String fileName; private CompoundTag levelData; private Image icon; - private boolean isLocked; private Path levelDataPath; public World(Path file) throws IOException { @@ -144,7 +142,19 @@ public final class World { } public boolean isLocked() { - return isLocked; + return isLocked(getSessionLockFile()); + } + + public boolean supportDatapacks() { + return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); + } + + public boolean supportQuickPlay() { + return getGameVersion() != null && getGameVersion().isAtLeast("1.20", "23w14a"); + } + + public static boolean supportQuickPlay(GameVersionNumber gameVersionNumber) { + return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.20", "23w14a"); } private void loadFromDirectory() throws IOException { @@ -153,9 +163,11 @@ public final class World { if (!Files.exists(levelDat)) { // version 20w14infinite levelDat = file.resolve("special_level.dat"); } + if (!Files.exists(levelDat)) { + throw new IOException("Not a valid world directory since level.dat or special_level.dat cannot be found."); + } loadAndCheckLevelDat(levelDat); this.levelDataPath = levelDat; - isLocked = isLocked(getSessionLockFile()); Path iconFile = file.resolve("icon.png"); if (Files.isRegularFile(iconFile)) { @@ -177,7 +189,6 @@ public final class World { if (!Files.exists(levelDat)) { throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); } - loadAndCheckLevelDat(levelDat); Path iconFile = root.resolve("icon.png"); @@ -193,10 +204,9 @@ public final class World { } private void loadFromZip() throws IOException { - isLocked = false; try (FileSystem fs = CompressingUtils.readonly(file).setAutoDetectEncoding(true).build()) { - Path cur = fs.getPath("/level.dat"); - if (Files.isRegularFile(cur)) { + Path levelDatPath = fs.getPath("/level.dat"); + if (Files.isRegularFile(levelDatPath)) { fileName = FileUtils.getName(file); loadFromZipImpl(fs.getPath("/")); return; @@ -230,6 +240,8 @@ public final class World { } } + // The rename method is used to rename temporary world object during installation and copying, + // so there is no need to modify the `file` field. public void rename(String newName) throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); @@ -257,14 +269,14 @@ public final class World { if (Files.isRegularFile(file)) { try (FileSystem fs = CompressingUtils.readonly(file).setAutoDetectEncoding(true).build()) { - Path cur = fs.getPath("/level.dat"); - if (Files.isRegularFile(cur)) { + Path levelDatPath = fs.getPath("/level.dat"); + if (Files.isRegularFile(levelDatPath)) { fileName = FileUtils.getName(file); new Unzipper(file, worldDir).unzip(); } else { try (Stream stream = Files.list(fs.getPath("/"))) { - List subDirs = stream.collect(Collectors.toList()); + List subDirs = stream.toList(); if (subDirs.size() != 1) { throw new IOException("World zip malformed"); } @@ -347,8 +359,8 @@ public final class World { private static CompoundTag parseLevelDat(Path path) throws IOException { try (InputStream is = new GZIPInputStream(Files.newInputStream(path))) { Tag nbt = NBTIO.readTag(is); - if (nbt instanceof CompoundTag) - return (CompoundTag) nbt; + if (nbt instanceof CompoundTag compoundTag) + return compoundTag; else throw new IOException("level.dat malformed"); } @@ -367,21 +379,21 @@ public final class World { } } - public static Stream getWorlds(Path savesDir) { - try { - if (Files.exists(savesDir)) { - return Files.list(savesDir).flatMap(world -> { + public static List getWorlds(Path savesDir) { + if (Files.exists(savesDir)) { + try (Stream stream = Files.list(savesDir)) { + return stream.flatMap(world -> { try { - return Stream.of(new World(world.toAbsolutePath())); + return Stream.of(new World(world.toAbsolutePath().normalize())); } catch (IOException e) { LOG.warning("Failed to read world " + world, e); return Stream.empty(); } - }); + }).toList(); + } catch (IOException e) { + LOG.warning("Failed to read saves", e); } - } catch (IOException e) { - LOG.warning("Failed to read saves", e); } - return Stream.empty(); + return List.of(); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index e8e1d92c6..466778182 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -322,7 +322,7 @@ public class DefaultLauncher extends Launcher { try { ServerAddress parsed = ServerAddress.parse(address); - if (GameVersionNumber.asGameVersion(gameVersion).isAtLeast("1.20", "23w14a")) { + if (World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlayMultiplayer"); res.add(parsed.getPort() >= 0 ? address : parsed.getHost() + ":25565"); } else { @@ -335,11 +335,11 @@ public class DefaultLauncher extends Launcher { LOG.warning("Invalid server address: " + address, e); } } else if (options.getQuickPlayOption() instanceof QuickPlayOption.SinglePlayer singlePlayer - && GameVersionNumber.asGameVersion(gameVersion).isAtLeast("1.20", "23w14a")) { + && World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlaySingleplayer"); res.add(singlePlayer.worldFolderName()); } else if (options.getQuickPlayOption() instanceof QuickPlayOption.Realm realm - && GameVersionNumber.asGameVersion(gameVersion).isAtLeast("1.20", "23w14a")) { + && World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlayRealms"); res.add(realm.realmID()); } 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 88670f265..5322e5b75 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -280,6 +280,15 @@ public final class Lang { } } + public static Float toFloatOrNull(Object string) { + try { + if (string == null) return null; + return Float.parseFloat(string.toString()); + } catch (NumberFormatException e) { + return null; + } + } + /** * Find the first non-null reference in given list. *