feat: 优化世界管理与世界信息页面 (#5215)

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
This commit is contained in:
mineDiamond
2026-01-29 22:39:47 +08:00
committed by GitHub
parent fe11e5ff1e
commit 27972b987e
19 changed files with 509 additions and 376 deletions

View File

@@ -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<DatapackListPageSkin.DatapackInfoObject> {
public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.DatapackInfoObject> 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<Path> 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<DatapackListPageSkin.Da
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
if (res != null) {
res.forEach(this::installSingleDatapack);
installMultiDatapack(res);
}
datapack.loadFromDir();

View File

@@ -114,16 +114,23 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
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<DatapackListPage> {
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<DatapackListPage> {
final TwoLineListItem content = new TwoLineListItem();
BooleanProperty booleanProperty;
DatapackInfoListCell(JFXListView<DatapackInfoObject> listView) {
DatapackInfoListCell(JFXListView<DatapackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
super(listView);
HBox container = new HBox(8);
@@ -312,6 +319,8 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
content.setMouseTransparent(true);
setSelectable();
checkBox.disableProperty().bind(isReadOnlyProperty);
imageView.setFitWidth(32);
imageView.setFitHeight(32);
imageView.setPreserveRatio(true);

View File

@@ -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<WorldBackupsPage.BackupInfo> {
public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.BackupInfo> 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("(?<datetime>[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?<count>[0-9]+))?\\.zip");
refresh();
@@ -164,7 +165,7 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
@Override
protected List<Node> 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),

View File

@@ -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<Difficulty> 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<GameType> playerGameTypeButton = new LineSelectButton<>();
LineSelectButton<GameType> 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<String> setNameCall) {
private void setRightTextLabel(BorderPane borderPane, Callable<String> 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) {

View File

@@ -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<World> implements VersionP
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private Path savesDir;
private Path backupsDir;
private List<World> worlds;
private Profile profile;
private String id;
private String instanceId;
private int refreshCount = 0;
@@ -88,9 +86,8 @@ public final class WorldListPage extends ListPageBase<World> 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<World> 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<World> 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<World> implements VersionP
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
// Ensure the game version number is parsed
profile.getRepository().getGameVersion(id);
try (Stream<World> 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<World> 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<World> 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<World> implements VersionP
@Override
protected List<Node> 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<World> 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<World> 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<World> 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<World> 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<World> 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(),

View File

@@ -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> 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> 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<WorldInfoPage> worldInfoTab = new TabHeader.Tab<>("worldInfoPage");
private final TabHeader.Tab<WorldBackupsPage> worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage");
private final TabHeader.Tab<DatapackListPage> 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<State> 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<WorldManagePage> {
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();
}
}

View File

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

View File

@@ -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));
}

View File

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

View File

@@ -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=العوالم

View File

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

View File

@@ -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=司生界

View File

@@ -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=Миры

View File

@@ -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=Світи

View File

@@ -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=該世界正在使用中,請關閉遊戲後重試。

View File

@@ -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=该世界正在使用中,请关闭游戏后重试。

View File

@@ -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<Path> stream = Files.list(fs.getPath("/"))) {
List<Path> subDirs = stream.collect(Collectors.toList());
List<Path> 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<World> getWorlds(Path savesDir) {
try {
if (Files.exists(savesDir)) {
return Files.list(savesDir).flatMap(world -> {
public static List<World> getWorlds(Path savesDir) {
if (Files.exists(savesDir)) {
try (Stream<Path> 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();
}
}

View File

@@ -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());
}

View File

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