From cd030c1de07bf5e51cec8e17bb317ab0075de08e Mon Sep 17 00:00:00 2001 From: huanghongxun Date: Sat, 25 Sep 2021 02:04:25 +0800 Subject: [PATCH] feat: support change skin of offline accounts. --- .../mickey/minecraft/skin/fx/SkinCanvas.java | 56 +++++- .../hmcl/ui/account/AccountListItem.java | 14 +- .../hmcl/ui/account/CreateAccountPane.java | 2 +- .../ui/account/OfflineAccountSkinPane.java | 171 +++++++++++++++++- .../hmcl/ui/construct/FileSelector.java | 105 +++++++++++ .../hmcl/ui/construct/MultiFileItem.java | 83 +++------ .../hmcl/ui/construct/URLValidator.java | 51 ++++++ .../hmcl/ui/export/ModpackInfoPage.java | 15 +- .../hmcl/ui/main/PersonalizationPage.java | 12 +- .../jackhuang/hmcl/ui/main/SettingsPage.java | 2 +- .../jackhuang/hmcl/ui/main/SettingsView.java | 11 +- .../hmcl/ui/versions/VersionSettingsPage.java | 35 ++-- HMCL/src/main/resources/assets/css/root.css | 4 + .../resources/assets/lang/I18N.properties | 10 +- .../resources/assets/lang/I18N_zh.properties | 18 +- .../assets/lang/I18N_zh_CN.properties | 8 + .../mickey/minecraft/skin/fx/test/Test.java | 6 +- .../hmcl/auth/offline/OfflineAccount.java | 38 ++-- .../auth/offline/OfflineAccountFactory.java | 21 +-- .../org/jackhuang/hmcl/auth/offline/Skin.java | 159 ++++++++++++++-- .../jackhuang/hmcl/auth/offline/Texture.java | 4 + .../hmcl/auth/offline/YggdrasilServer.java | 34 +--- .../java/org/jackhuang/hmcl/task/Task.java | 4 +- .../org/jackhuang/hmcl/util/io/FileUtils.java | 1 + 24 files changed, 672 insertions(+), 192 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/URLValidator.java diff --git a/HMCL/src/main/java/moe/mickey/minecraft/skin/fx/SkinCanvas.java b/HMCL/src/main/java/moe/mickey/minecraft/skin/fx/SkinCanvas.java index 95530ee05..759c32a2b 100644 --- a/HMCL/src/main/java/moe/mickey/minecraft/skin/fx/SkinCanvas.java +++ b/HMCL/src/main/java/moe/mickey/minecraft/skin/fx/SkinCanvas.java @@ -1,12 +1,9 @@ package moe.mickey.minecraft.skin.fx; -import javafx.scene.Group; -import javafx.scene.Node; -import javafx.scene.PerspectiveCamera; -import javafx.scene.SceneAntialiasing; -import javafx.scene.SubScene; +import javafx.scene.*; import javafx.scene.image.Image; -import javafx.scene.paint.Color; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; import javafx.scene.paint.Material; import javafx.scene.paint.PhongMaterial; import javafx.scene.shape.Shape3D; @@ -16,9 +13,8 @@ import javafx.scene.transform.Translate; public class SkinCanvas extends Group { - public static final Image ALEX = new Image(SkinCanvas.class.getResourceAsStream("/alex.png")); - public static final Image STEVE = new Image(SkinCanvas.class.getResourceAsStream("/steve.png")); - public static final Image CHOCOLATE = new Image(SkinCanvas.class.getResourceAsStream("/chocolate.png")); + public static final Image ALEX = new Image(SkinCanvas.class.getResourceAsStream("/assets/img/alex.png")); + public static final Image STEVE = new Image(SkinCanvas.class.getResourceAsStream("/assets/img//steve.png")); public static final SkinCube ALEX_LARM = new SkinCube(3, 12, 4, 14F / 64F, 16F / 64F, 32F / 64F, 48F / 64F, 0F, true); public static final SkinCube ALEX_RARM = new SkinCube(3, 12, 4, 14F / 64F, 16F / 64F, 40F / 64F, 16F / 64F, 0F, true); @@ -208,7 +204,6 @@ public class SkinCanvas extends Group { subScene = new SubScene(group, preW, preH, true, msaa ? SceneAntialiasing.BALANCED : SceneAntialiasing.DISABLED); - subScene.setFill(Color.ALICEBLUE); subScene.setCamera(camera); return subScene; @@ -218,4 +213,45 @@ public class SkinCanvas extends Group { getChildren().add(createSubScene()); } + private double lastX, lastY; + + public void enableRotation(double sensitivity) { + addEventHandler(MouseEvent.MOUSE_PRESSED, e -> { + lastX = -1; + lastY = -1; + }); + addEventHandler(MouseEvent.MOUSE_DRAGGED, e -> { + if (!(lastX == -1 || lastY == -1)) { + if (e.isAltDown() || e.isControlDown() || e.isShiftDown()) { + if (e.isShiftDown()) + zRotate.setAngle(zRotate.getAngle() - (e.getSceneY() - lastY) * sensitivity); + if (e.isAltDown()) + yRotate.setAngle(yRotate.getAngle() + (e.getSceneX() - lastX) * sensitivity); + if (e.isControlDown()) + xRotate.setAngle(xRotate.getAngle() + (e.getSceneY() - lastY) * sensitivity); + } else { + double yaw = yRotate.getAngle() + (e.getSceneX() - lastX) * sensitivity; + yaw %= 360; + if (yaw < 0) + yaw += 360; + + int flagX = yaw < 90 || yaw > 270 ? 1 : -1; + int flagZ = yaw < 180 ? -1 : 1; + double kx = Math.abs(90 - yaw % 180) / 90 * flagX, kz = Math.abs(90 - (yaw + 90) % 180) / 90 * flagZ; + + xRotate.setAngle(xRotate.getAngle() + (e.getSceneY() - lastY) * sensitivity * kx); + yRotate.setAngle(yaw); + zRotate.setAngle(zRotate.getAngle() + (e.getSceneY() - lastY) * sensitivity * kz); + } + } + lastX = e.getSceneX(); + lastY = e.getSceneY(); + }); + addEventHandler(ScrollEvent.SCROLL, e -> { + double delta = (e.getDeltaY() > 0 ? 1 : e.getDeltaY() == 0 ? 0 : -1) / 10D * sensitivity; + scale.setX(Math.min(Math.max(scale.getX() - delta, 0.1), 10)); + scale.setY(Math.min(Math.max(scale.getY() - delta, 0.1), 10)); + }); + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java index fcdb3efda..48e3cc572 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java @@ -20,7 +20,10 @@ package org.jackhuang.hmcl.ui.account; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.StringBinding; -import javafx.beans.property.*; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.beans.value.ObservableBooleanValue; import javafx.scene.control.RadioButton; import javafx.scene.control.Skin; @@ -46,6 +49,7 @@ import org.jackhuang.hmcl.util.skin.InvalidSkinException; import org.jackhuang.hmcl.util.skin.NormalizedSkin; import org.jetbrains.annotations.Nullable; +import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -54,8 +58,6 @@ import java.util.Set; import java.util.concurrent.CancellationException; import java.util.logging.Level; -import javax.imageio.ImageIO; - import static java.util.Collections.emptySet; import static javafx.beans.binding.Bindings.createBooleanBinding; import static org.jackhuang.hmcl.util.Logging.LOG; @@ -135,6 +137,8 @@ public class AccountListItem extends RadioButton { } else { return createBooleanBinding(() -> true); } + } else if (account instanceof OfflineAccount) { + return createBooleanBinding(() -> true); } else { return createBooleanBinding(() -> false); } @@ -145,6 +149,10 @@ public class AccountListItem extends RadioButton { */ @Nullable public Task uploadSkin() { + if (account instanceof OfflineAccount) { + Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); + return null; + } if (!(account instanceof YggdrasilAccount)) { return null; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index 16c57667c..a386ef82f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -523,7 +523,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware { return getAuthServer(); } else if (factory instanceof OfflineAccountFactory) { UUID uuid = txtUUID == null ? null : StringUtils.isBlank(txtUUID.getText()) ? null : UUIDTypeAdapter.fromString(txtUUID.getText()); - return new OfflineAccountFactory.AdditionalData(uuid, null, null); + return new OfflineAccountFactory.AdditionalData(uuid, null); } else { return null; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java index 4cb90212b..9bb9d5f96 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java @@ -17,19 +17,184 @@ */ package org.jackhuang.hmcl.ui.account; +import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; -import javafx.scene.layout.StackPane; +import com.jfoenix.controls.JFXTextField; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.geometry.Insets; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.input.DragEvent; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.*; +import moe.mickey.minecraft.skin.fx.SkinCanvas; +import moe.mickey.minecraft.skin.fx.animation.SkinAniRunning; +import moe.mickey.minecraft.skin.fx.animation.SkinAniWavingArms; import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.ui.construct.MultiFileItem; +import org.jackhuang.hmcl.auth.offline.Skin; +import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.*; + +import java.io.File; +import java.util.Arrays; +import java.util.logging.Level; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class OfflineAccountSkinPane extends StackPane { + private final OfflineAccount account; + + private final MultiFileItem skinItem = new MultiFileItem<>(); + private final JFXTextField cslApiField = new JFXTextField(); + private final FileSelector skinSelector = new FileSelector(); + private final FileSelector capeSelector = new FileSelector(); + + private final InvalidationListener skinBinding; public OfflineAccountSkinPane(OfflineAccount account) { + this.account = account; + + getStyleClass().add("skin-pane"); JFXDialogLayout layout = new JFXDialogLayout(); getChildren().setAll(layout); + layout.setHeading(new Label(i18n("account.skin"))); - MultiFileItem<> + BorderPane pane = new BorderPane(); + SkinCanvas canvas = new SkinCanvas(SkinCanvas.STEVE, 300, 300, true); + StackPane canvasPane = new StackPane(canvas); + canvasPane.setPrefWidth(300); + canvasPane.setPrefHeight(300); + pane.setCenter(canvas); + canvas.getAnimationplayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); + canvas.enableRotation(.5); + + canvas.addEventHandler(DragEvent.DRAG_OVER, e -> { + if (e.getDragboard().hasFiles()) { + File file = e.getDragboard().getFiles().get(0); + if (file.getAbsolutePath().endsWith(".png")) + e.acceptTransferModes(TransferMode.COPY); + } + }); + canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> { + if (e.isAccepted()) { + File skin = e.getDragboard().getFiles().get(0); + Platform.runLater(() -> { + skinSelector.setValue(skin.getAbsolutePath()); + skinItem.setSelectedData(Skin.Type.LOCAL_FILE); + }); + } + }); + + TransitionPane skinOptionPane = new TransitionPane(); + skinOptionPane.setMaxWidth(300); + VBox optionPane = new VBox(skinItem, skinOptionPane); + pane.setRight(optionPane); + + layout.setBody(pane); + + cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint")); + cslApiField.setValidators(new URLValidator()); + + skinItem.loadChildren(Arrays.asList( + new MultiFileItem.Option<>(i18n("message.default"), Skin.Type.DEFAULT), + new MultiFileItem.Option<>("Steve", Skin.Type.STEVE), + new MultiFileItem.Option<>("Alex", Skin.Type.ALEX), + new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), Skin.Type.LOCAL_FILE), + new MultiFileItem.Option<>("LittleSkin", Skin.Type.LITTLE_SKIN), + new MultiFileItem.Option<>(i18n("account.skin.type.csl_api"), Skin.Type.CUSTOM_SKIN_LOADER_API) + )); + + if (account.getSkin() == null) { + skinItem.setSelectedData(Skin.Type.DEFAULT); + } else { + skinItem.setSelectedData(account.getSkin().getType()); + cslApiField.setText(account.getSkin().getCslApi()); + skinSelector.setValue(account.getSkin().getLocalSkinPath()); + capeSelector.setValue(account.getSkin().getLocalCapePath()); + } + + skinBinding = FXUtils.observeWeak(() -> { + getSkin().load(account.getUsername()) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.log(Level.WARNING, "Failed to load skin", exception); + Controllers.showToast(i18n("message.failed")); + } else { + if (result == null || result.getSkin() == null) { + canvas.updateSkin(getDefaultTexture(), isDefaultSlim()); + return; + } + canvas.updateSkin(new Image(result.getSkin().getInputStream()), result.getModel() == TextureModel.ALEX); + } + }).start(); + }, skinItem.selectedDataProperty(), cslApiField.textProperty(), skinSelector.valueProperty(), capeSelector.valueProperty()); + + FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { + GridPane gridPane = new GridPane(); + gridPane.setPadding(new Insets(0, 0, 0, 10)); + gridPane.setHgap(16); + gridPane.setVgap(8); + gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); + + switch (selectedData) { + case DEFAULT: + case STEVE: + case ALEX: + case LITTLE_SKIN: + break; + case LOCAL_FILE: + gridPane.addRow(0, new Label(i18n("account.skin")), skinSelector); + gridPane.addRow(1, new Label(i18n("account.cape")), capeSelector); + break; + case CUSTOM_SKIN_LOADER_API: + gridPane.addRow(0, new Label(i18n("account.skin.type.csl_api.location")), cslApiField); + break; + } + + skinOptionPane.setContent(gridPane, ContainerAnimations.NONE.getAnimationProducer()); + }); + + JFXButton acceptButton = new JFXButton(i18n("button.ok")); + acceptButton.getStyleClass().add("dialog-accept"); + acceptButton.setOnAction(e -> { + account.setSkin(getSkin()); + fireEvent(new DialogCloseEvent()); + }); + + JFXHyperlink littleSkinLink = new JFXHyperlink(i18n("account.skin.type.little_skin.hint")); + littleSkinLink.setOnAction(e -> FXUtils.openLink("https://mcskin.littleservice.cn/")); + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + onEscPressed(this, cancelButton::fire); + + layout.setActions(littleSkinLink, acceptButton, cancelButton); } + + private Skin getSkin() { + return new Skin(skinItem.getSelectedData(), cslApiField.getText(), skinSelector.getValue(), capeSelector.getValue()); + } + + private boolean isDefaultSlim() { + return TextureModel.detectUUID(account.getUUID()) == TextureModel.ALEX; + } + + private Image getDefaultTexture() { + if (isDefaultSlim()) { + return SkinCanvas.ALEX; + } else { + return SkinCanvas.STEVE; + } + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java new file mode 100644 index 000000000..2a288c3d2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java @@ -0,0 +1,105 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXTextField; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.layout.HBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.SVG; + +import java.io.File; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class FileSelector extends HBox { + private StringProperty value = new SimpleStringProperty(); + private String chooserTitle = i18n("selector.choose_file"); + private boolean directory = false; + private final ObservableList extensionFilters = FXCollections.observableArrayList(); + + public String getValue() { + return value.get(); + } + + public StringProperty valueProperty() { + return value; + } + + public void setValue(String value) { + this.value.set(value); + } + + public String getChooserTitle() { + return chooserTitle; + } + + public FileSelector setChooserTitle(String chooserTitle) { + this.chooserTitle = chooserTitle; + return this; + } + + public boolean isDirectory() { + return directory; + } + + public FileSelector setDirectory(boolean directory) { + this.directory = directory; + return this; + } + + public ObservableList getExtensionFilters() { + return extensionFilters; + } + + public FileSelector() { + JFXTextField customField = new JFXTextField(); + customField.textProperty().bindBidirectional(valueProperty()); + + JFXButton selectButton = new JFXButton(); + selectButton.setGraphic(SVG.folderOpen(Theme.blackFillBinding(), 15, 15)); + selectButton.setOnMouseClicked(e -> { + if (directory) { + DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(chooserTitle); + File dir = chooser.showDialog(Controllers.getStage()); + if (dir != null) + customField.setText(dir.getAbsolutePath()); + } else { + FileChooser chooser = new FileChooser(); + chooser.getExtensionFilters().addAll(getExtensionFilters()); + chooser.setTitle(chooserTitle); + File file = chooser.showOpenDialog(Controllers.getStage()); + if (file != null) + customField.setText(file.getAbsolutePath()); + } + }); + + setAlignment(Pos.CENTER_LEFT); + setSpacing(3); + getChildren().addAll(customField, selectButton); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java index 2436d9589..9cb76f908 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java @@ -17,11 +17,10 @@ */ package org.jackhuang.hmcl.ui.construct; -import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.controls.JFXTextField; +import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.property.*; -import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -31,39 +30,28 @@ import javafx.scene.control.Toggle; import javafx.scene.control.ToggleGroup; import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; -import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.util.StringUtils; -import java.io.File; import java.util.Collection; import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Collectors; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public class MultiFileItem extends ComponentSublist { +public class MultiFileItem extends VBox { private final ObjectProperty selectedData = new SimpleObjectProperty<>(this, "selectedData"); private final ObjectProperty fallbackData = new SimpleObjectProperty<>(this, "fallbackData"); private final ToggleGroup group = new ToggleGroup(); - private final VBox pane = new VBox(); private Consumer toggleSelectedListener; @SuppressWarnings("unchecked") public MultiFileItem() { - pane.setStyle("-fx-padding: 0 0 10 0;"); - pane.setSpacing(8); - - getContent().add(pane); + setPadding(new Insets(0, 0, 10, 0)); + setSpacing(8); group.selectedToggleProperty().addListener((a, b, newValue) -> { if (toggleSelectedListener != null) @@ -86,7 +74,7 @@ public class MultiFileItem extends ComponentSublist { } public void loadChildren(Collection> options) { - pane.getChildren().setAll(options.stream() + getChildren().setAll(options.stream() .map(option -> option.createItem(group)) .collect(Collectors.toList())); } @@ -183,6 +171,7 @@ public class MultiFileItem extends ComponentSublist { public static class StringOption extends Option { private StringProperty value = new SimpleStringProperty(); + private ValidatorBase[] validators; public StringOption(String title, T data) { super(title, data); @@ -205,6 +194,11 @@ public class MultiFileItem extends ComponentSublist { return this; } + public StringOption setValidators(ValidatorBase... validators) { + this.validators = validators; + return this; + } + @Override protected Node createItem(ToggleGroup group) { BorderPane pane = new BorderPane(); @@ -221,6 +215,9 @@ public class MultiFileItem extends ComponentSublist { BorderPane.setAlignment(customField, Pos.CENTER_RIGHT); customField.textProperty().bindBidirectional(valueProperty()); customField.disableProperty().bind(left.selectedProperty().not()); + if (validators != null) { + customField.setValidators(validators); + } pane.setRight(customField); return pane; @@ -228,44 +225,41 @@ public class MultiFileItem extends ComponentSublist { } public static class FileOption extends Option { - private StringProperty value = new SimpleStringProperty(); - private String chooserTitle = i18n("selector.choose_file"); - private boolean directory = false; - private final ObservableList extensionFilters = FXCollections.observableArrayList(); + private FileSelector selector = new FileSelector(); public FileOption(String title, T data) { super(title, data); } public String getValue() { - return value.get(); + return selector.getValue(); } public StringProperty valueProperty() { - return value; + return selector.valueProperty(); } public void setValue(String value) { - this.value.set(value); + selector.setValue(value); } public FileOption setDirectory(boolean directory) { - this.directory = directory; + selector.setDirectory(directory); return this; } public FileOption bindBidirectional(Property property) { - this.value.bindBidirectional(property); + selector.valueProperty().bindBidirectional(property); return this; } public FileOption setChooserTitle(String chooserTitle) { - this.chooserTitle = chooserTitle; + selector.setChooserTitle(chooserTitle); return this; } public ObservableList getExtensionFilters() { - return extensionFilters; + return selector.getExtensionFilters(); } @Override @@ -280,36 +274,9 @@ public class MultiFileItem extends ComponentSublist { left.setUserData(data); pane.setLeft(left); - JFXTextField customField = new JFXTextField(); - customField.textProperty().bindBidirectional(valueProperty()); - customField.disableProperty().bind(left.selectedProperty().not()); - - JFXButton selectButton = new JFXButton(); - selectButton.disableProperty().bind(left.selectedProperty().not()); - selectButton.setGraphic(SVG.folderOpen(Theme.blackFillBinding(), 15, 15)); - selectButton.setOnMouseClicked(e -> { - if (directory) { - DirectoryChooser chooser = new DirectoryChooser(); - chooser.setTitle(chooserTitle); - File dir = chooser.showDialog(Controllers.getStage()); - if (dir != null) - customField.setText(dir.getAbsolutePath()); - } else { - FileChooser chooser = new FileChooser(); - chooser.getExtensionFilters().addAll(getExtensionFilters()); - chooser.setTitle(chooserTitle); - File file = chooser.showOpenDialog(Controllers.getStage()); - if (file != null) - customField.setText(file.getAbsolutePath()); - } - }); - - HBox right = new HBox(); - right.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(right, Pos.CENTER_RIGHT); - right.setSpacing(3); - right.getChildren().addAll(customField, selectButton); - pane.setRight(right); + selector.disableProperty().bind(left.selectedProperty().not()); + BorderPane.setAlignment(selector, Pos.CENTER_RIGHT); + pane.setRight(selector); return pane; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/URLValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/URLValidator.java new file mode 100644 index 000000000..dfd8a5864 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/URLValidator.java @@ -0,0 +1,51 @@ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.NamedArg; +import javafx.scene.control.TextInputControl; +import org.jackhuang.hmcl.util.StringUtils; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class URLValidator extends ValidatorBase { + private final boolean nullable; + + public URLValidator() { + this(false); + } + + public URLValidator(@NamedArg("nullable") boolean nullable) { + this(i18n("input.url"), nullable); + } + + public URLValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) { + super(message); + this.nullable = nullable; + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + + if (StringUtils.isBlank(textField.getText())) + hasErrors.set(!nullable); + else { + try { + new URL(textField.getText()).toURI(); + hasErrors.set(false); + } catch (IOException | URISyntaxException e) { + hasErrors.set(true); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java index 07f2847ea..8660a236a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java @@ -46,9 +46,6 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.File; -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; @@ -243,17 +240,7 @@ public final class ModpackInfoPage extends Control implements WizardPage { txtModpackFileApi.getValidators().add(new RequiredValidator()); } - txtModpackFileApi.getValidators().add(new Validator(s -> { - if (s.isEmpty()) { - return true; - } - try { - new URL(s).toURI(); - return true; - } catch (IOException | URISyntaxException e) { - return false; - } - })); + txtModpackFileApi.getValidators().add(new URLValidator()); pane.addRow(rowIndex++, new Label(i18n("modpack.file_api")), txtModpackFileApi); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java index 6d597bff8..da03742e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java @@ -86,11 +86,13 @@ public class PersonalizationPage extends StackPane { } { - StackPane componentList = new StackPane(); + ComponentList componentList = new ComponentList(); MultiFileItem backgroundItem = new MultiFileItem<>(); - backgroundItem.setTitle(i18n("launcher.background")); - backgroundItem.setHasSubtitle(true); + ComponentSublist backgroundSublist = new ComponentSublist(); + backgroundSublist.getContent().add(backgroundItem); + backgroundSublist.setTitle(i18n("launcher.background")); + backgroundSublist.setHasSubtitle(true); backgroundItem.loadChildren(Arrays.asList( new MultiFileItem.Option<>(i18n("launcher.background.default"), EnumBackgroundImage.DEFAULT), @@ -102,12 +104,12 @@ public class PersonalizationPage extends StackPane { .bindBidirectional(config().backgroundImageUrlProperty()) )); backgroundItem.selectedDataProperty().bindBidirectional(config().backgroundImageTypeProperty()); - backgroundItem.subtitleProperty().bind( + backgroundSublist.subtitleProperty().bind( new When(backgroundItem.selectedDataProperty().isEqualTo(EnumBackgroundImage.DEFAULT)) .then(i18n("launcher.background.default")) .otherwise(config().backgroundImageProperty())); - componentList.getChildren().add(backgroundItem); + componentList.getContent().add(backgroundItem); content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("launcher.background")), componentList); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index 74f44a23c..a0e3ab09e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -64,7 +64,7 @@ public final class SettingsPage extends SettingsView { // ==== fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty()); - fileCommonLocation.subtitleProperty().bind( + fileCommonLocationSublist.subtitleProperty().bind( Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory()) .orElse(i18n("launcher.cache_directory.disabled")), config().commonDirectoryProperty(), config().commonDirTypeProperty())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java index 487ef2d0d..af9a3477b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java @@ -48,6 +48,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public abstract class SettingsView extends StackPane { protected final JFXComboBox cboLanguage; protected final MultiFileItem fileCommonLocation; + protected final ComponentSublist fileCommonLocationSublist; protected final Label lblUpdate; protected final Label lblUpdateSub; protected final JFXRadioButton chkUpdateStable; @@ -144,8 +145,10 @@ public abstract class SettingsView extends StackPane { { fileCommonLocation = new MultiFileItem<>(); - fileCommonLocation.setTitle(i18n("launcher.cache_directory")); - fileCommonLocation.setHasSubtitle(true); + fileCommonLocationSublist = new ComponentSublist(); + fileCommonLocationSublist.getContent().add(fileCommonLocation); + fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory")); + fileCommonLocationSublist.setHasSubtitle(true); fileCommonLocation.loadChildren(Arrays.asList( new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT), new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM) @@ -159,10 +162,10 @@ public abstract class SettingsView extends StackPane { cleanButton.setOnMouseClicked(e -> clearCacheDirectory()); cleanButton.getStyleClass().add("jfx-button-border"); - fileCommonLocation.setHeaderRight(cleanButton); + fileCommonLocationSublist.setHeaderRight(cleanButton); } - settingsPane.getContent().add(fileCommonLocation); + settingsPane.getContent().add(fileCommonLocationSublist); } { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java index a3dc896b1..e9cbd8c26 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java @@ -99,10 +99,13 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag private final OptionToggleButton noJVMCheckPane; private final OptionToggleButton useNativeGLFWPane; private final OptionToggleButton useNativeOpenALPane; + private final ComponentSublist javaSublist; private final MultiFileItem javaItem; private final MultiFileItem.FileOption javaCustomOption; + private final ComponentSublist gameDirSublist; private final MultiFileItem gameDirItem; private final MultiFileItem.FileOption gameDirCustomOption; + private final ComponentSublist nativesDirSublist; private final MultiFileItem nativesDirItem; private final MultiFileItem.FileOption nativesDirCustomOption; private final JFXComboBox cboProcessPriority; @@ -192,14 +195,18 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag componentList.setDepth(1); javaItem = new MultiFileItem<>(); - javaItem.setTitle(i18n("settings.game.java_directory")); - javaItem.setHasSubtitle(true); + javaSublist = new ComponentSublist(); + javaSublist.getContent().add(javaItem); + javaSublist.setTitle(i18n("settings.game.java_directory")); + javaSublist.setHasSubtitle(true); javaCustomOption = new MultiFileItem.FileOption(i18n("settings.custom"), null) .setChooserTitle(i18n("settings.game.java_directory.choose")); gameDirItem = new MultiFileItem<>(); - gameDirItem.setTitle(i18n("settings.game.working_directory")); - gameDirItem.setHasSubtitle(true); + gameDirSublist = new ComponentSublist(); + gameDirSublist.getContent().add(gameDirItem); + gameDirSublist.setTitle(i18n("settings.game.working_directory")); + gameDirSublist.setHasSubtitle(true); gameDirItem.disableProperty().bind(modpack); gameDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.custom"), GameDirectoryType.CUSTOM) .setChooserTitle(i18n("settings.game.working_directory.choose")) @@ -395,7 +402,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag serverPane.addRow(0, new Label(i18n("settings.advanced.server_ip")), txtServerIP); } - componentList.getContent().setAll(javaItem, gameDirItem, maxMemoryPane, launcherVisibilityPane, dimensionPane, showLogsPane, processPriorityPane, serverPane); + componentList.getContent().setAll(javaSublist, gameDirSublist, maxMemoryPane, launcherVisibilityPane, dimensionPane, showLogsPane, processPriorityPane, serverPane); } HBox advancedHintPane = new HBox(); @@ -474,8 +481,10 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag workaroundPane.disableProperty().bind(enableSpecificSettings.not()); { nativesDirItem = new MultiFileItem<>(); - nativesDirItem.setTitle(i18n("settings.advanced.natives_directory")); - nativesDirItem.setHasSubtitle(true); + nativesDirSublist = new ComponentSublist(); + nativesDirSublist.getContent().add(nativesDirItem); + nativesDirSublist.setTitle(i18n("settings.advanced.natives_directory")); + nativesDirSublist.setHasSubtitle(true); nativesDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.custom"), NativesDirectoryType.CUSTOM) .setChooserTitle(i18n("settings.advanced.natives_directory.choose")) .setDirectory(true); @@ -499,7 +508,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag useNativeOpenALPane = new OptionToggleButton(); useNativeOpenALPane.setTitle(i18n("settings.advanced.use_native_openal")); - workaroundPane.getContent().setAll(nativesDirItem, noJVMArgsPane, noGameCheckPane, noJVMCheckPane, useNativeGLFWPane, useNativeOpenALPane); + workaroundPane.getContent().setAll(nativesDirSublist, noJVMArgsPane, noGameCheckPane, noJVMCheckPane, useNativeGLFWPane, useNativeOpenALPane); } rootPane.getChildren().addAll(componentList, @@ -613,10 +622,10 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag lastVersionSetting.javaProperty().removeListener(javaListener); gameDirItem.selectedDataProperty().unbindBidirectional(lastVersionSetting.gameDirTypeProperty()); - gameDirItem.subtitleProperty().unbind(); + gameDirSublist.subtitleProperty().unbind(); nativesDirItem.selectedDataProperty().unbindBidirectional(lastVersionSetting.nativesDirTypeProperty()); - nativesDirItem.subtitleProperty().unbind(); + nativesDirSublist.subtitleProperty().unbind(); } // unbind data fields @@ -663,11 +672,11 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag versionSetting.javaProperty().addListener(javaListener); gameDirItem.selectedDataProperty().bindBidirectional(versionSetting.gameDirTypeProperty()); - gameDirItem.subtitleProperty().bind(Bindings.createStringBinding(() -> Paths.get(profile.getRepository().getRunDirectory(versionId).getAbsolutePath()).normalize().toString(), + gameDirSublist.subtitleProperty().bind(Bindings.createStringBinding(() -> Paths.get(profile.getRepository().getRunDirectory(versionId).getAbsolutePath()).normalize().toString(), versionSetting.gameDirProperty(), versionSetting.gameDirTypeProperty())); nativesDirItem.selectedDataProperty().bindBidirectional(versionSetting.nativesDirTypeProperty()); - nativesDirItem.subtitleProperty().bind(Bindings.createStringBinding(() -> Paths.get(profile.getRepository().getRunDirectory(versionId).getAbsolutePath() + "/natives").normalize().toString(), + nativesDirSublist.subtitleProperty().bind(Bindings.createStringBinding(() -> Paths.get(profile.getRepository().getRunDirectory(versionId).getAbsolutePath() + "/natives").normalize().toString(), versionSetting.nativesDirProperty(), versionSetting.nativesDirTypeProperty())); lastVersionSetting = versionSetting; @@ -703,7 +712,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag if (versionSetting == null) return; Task.supplyAsync(versionSetting::getJavaVersion) - .thenAcceptAsync(Schedulers.javafx(), javaVersion -> javaItem.setSubtitle(Optional.ofNullable(javaVersion) + .thenAcceptAsync(Schedulers.javafx(), javaVersion -> javaSublist.setSubtitle(Optional.ofNullable(javaVersion) .map(JavaVersion::getBinary).map(Path::toString).orElse("Invalid Java Path"))) .start(); } diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index da666f05c..41e8058e6 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -91,6 +91,10 @@ -fx-fill: #856404; } +.skin-pane .jfx-text-field { + -fx-pref-width: 200; +} + .memory-label { } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index e4ff04fcd..c556f0af3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -50,6 +50,7 @@ about.open_source=Open Source about.open_source.statement=GPL v3 (https://github.com/huanghongxun/HMCL/) account=Accounts +account.cape=Cape account.character=character account.choose=Choose a character account.create=Create a new account @@ -110,7 +111,13 @@ account.missing=No Account account.missing.add=Click here to add account.not_logged_in=Not logged in account.password=Password -account.skin.file=Skin file +account.skin=Skin +account.skin.file=Skin image file +account.skin.type.csl_api=Blessing Skin +account.skin.type.csl_api.location=Address +account.skin.type.csl_api.location.hint=CustomSkinAPI +account.skin.type.little_skin.hint=LittleSkin +account.skin.type.local_file=Local skin image file account.skin.upload=Upload skin account.skin.upload.failed=Failed to upload skin account.skin.invalid_skin=Unrecognized skin file @@ -459,6 +466,7 @@ main_page=Home message.cancelled=Operation was cancelled message.confirm=Confirm message.copied=Copied to clipboard +message.default=Default message.doing=Please wait message.downloading=Downloading... message.error=Error diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 5ad453008..c04ee47d2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -35,14 +35,14 @@ about.thanks_to=鳴謝 about.thanks_to.bangbang93.statement=提供 BMCLAPI 下載源,請贊助支持 BMCLAPI! about.thanks_to.contributors=所有通過 Issues、Pull Requests 等管道參與本項目的貢獻者 about.thanks_to.contributors.statement=沒有開源社區的支持,Hello Minecraft! Launcher 無法走到今天 -about.thanks_to.gamerteam.statement=提供默認背景圖 +about.thanks_to.gamerteam.statement=提供預設背景圖 about.thanks_to.mcbbs=MCBBS 我的世界中文論壇 about.thanks_to.mcbbs.statement=提供 MCBBS 下載源 about.thanks_to.mcmod=MC 百科 about.thanks_to.mcmod.statement=提供 Mod 中文名映射表與 Mod 百科 about.thanks_to.noin=這裡 (noin.cn) about.thanks_to.noin.statement=提供多人聯機服務 (cato - ioi 系列作品) -about.thanks_to.red_lnn.statement=提供默認背景圖 +about.thanks_to.red_lnn.statement=提供預設背景圖 about.thanks_to.users=HMCL 用戶群成員 about.thanks_to.users.statement=感謝用戶群成員贊助充電、積極催更、迴響問題、出謀劃策 about.thanks_to.yushijinhun.statement=authlib-injector 相关支援 @@ -50,6 +50,7 @@ about.open_source=開放原始碼 about.open_source.statement=GPL v3 (https://github.com/huanghongxun/HMCL/) account=帳戶 +account.cape=披風 account.character=角色 account.choose=請選擇角色 account.create=建立帳戶 @@ -110,7 +111,13 @@ account.missing=沒有遊戲帳戶 account.missing.add=按一下此處加入帳戶 account.not_logged_in=未登入 account.password=密碼 +account.skin=皮膚 account.skin.file=皮膚圖片檔案 +account.skin.type.csl_api=Blessing Skin 伺服器 +account.skin.type.csl_api.location=伺服器位址 +account.skin.type.csl_api.location.hint=CustomSkinAPI 位址 +account.skin.type.little_skin.hint=LittleSkin 皮膚站 +account.skin.type.local_file=本地皮膚圖片檔案 account.skin.upload=上傳皮膚 account.skin.upload.failed=皮膚上傳失敗 account.skin.invalid_skin=無法識別的皮膚檔案 @@ -459,6 +466,7 @@ main_page=首頁 message.cancelled=操作被取消 message.confirm=提示 message.copied=已複製到剪貼板 +message.default=預設 message.doing=請耐心等待 message.downloading=正在下載… message.error=錯誤 @@ -708,14 +716,14 @@ settings.advanced.java_permanent_generation_space=記憶體永久儲存區域 settings.advanced.java_permanent_generation_space.prompt=格式: MB settings.advanced.jvm=Java 虛擬機設定 settings.advanced.jvm_args=Java 虛擬機參數 -settings.advanced.jvm_args.prompt=填寫此處可以覆蓋默認設定 +settings.advanced.jvm_args.prompt=填寫此處可以覆蓋預設設定 settings.advanced.launcher_visibility.close=遊戲啟動後結束啟動器 settings.advanced.launcher_visibility.hide=遊戲啟動後隱藏啟動器 settings.advanced.launcher_visibility.hide_and_reopen=隱藏啟動器並在遊戲結束後重新開啟 settings.advanced.launcher_visibility.keep=不隱藏啟動器 settings.advanced.launcher_visible=啟動器可見性 settings.advanced.minecraft_arguments=Minecraft 額外參數 -settings.advanced.minecraft_arguments.prompt=默認 +settings.advanced.minecraft_arguments.prompt=預設 settings.advanced.natives_directory=本地庫路徑(LWJGL) settings.advanced.natives_directory.choose=選擇本地庫路徑 settings.advanced.natives_directory.default=預設(.minecraft/versions/<版本名>/natives/) @@ -731,7 +739,7 @@ settings.advanced.process_priority.high=高(優先保證遊戲運行,但可 settings.advanced.post_exit_command=遊戲結束後執行命令 settings.advanced.post_exit_command.prompt=將在遊戲結束後呼叫使用 settings.advanced.server_ip=伺服器位址 -settings.advanced.server_ip.prompt=默認,啟動遊戲後直接進入對應伺服器 +settings.advanced.server_ip.prompt=預設,啟動遊戲後直接進入對應伺服器 settings.advanced.use_native_glfw=[Linux] 使用系統 GLFW settings.advanced.use_native_openal=[Linux] 使用系統 OpenAL settings.advanced.workaround=除錯選項 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 8b009ca88..59f1342b5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -50,6 +50,7 @@ about.open_source=开源 about.open_source.statement=GPL v3 (https://github.com/huanghongxun/HMCL/) account=帐户 +account.cape=披风 account.character=角色 account.choose=选择一个角色 account.create=添加帐户 @@ -110,7 +111,13 @@ account.missing=没有游戏帐户 account.missing.add=点击此处添加帐户 account.not_logged_in=未登录 account.password=密码 +account.skin=皮肤 account.skin.file=皮肤图片文件 +account.skin.type.csl_api=Blessing Skin 服务器 +account.skin.type.csl_api.location=服务器地址 +account.skin.type.csl_api.location.hint=CustomSkinAPI 地址 +account.skin.type.little_skin.hint=LittleSkin 皮肤站 +account.skin.type.local_file=本地皮肤图片文件 account.skin.upload=上传皮肤 account.skin.upload.failed=皮肤上传失败 account.skin.invalid_skin=无法识别的皮肤文件 @@ -459,6 +466,7 @@ main_page=主页 message.cancelled=操作被取消 message.confirm=提示 message.copied=已复制到剪贴板 +message.default=默认 message.doing=请耐心等待 message.downloading=正在下载 message.error=错误 diff --git a/HMCL/src/test/java/moe/mickey/minecraft/skin/fx/test/Test.java b/HMCL/src/test/java/moe/mickey/minecraft/skin/fx/test/Test.java index 9edb06888..dacb69f1c 100644 --- a/HMCL/src/test/java/moe/mickey/minecraft/skin/fx/test/Test.java +++ b/HMCL/src/test/java/moe/mickey/minecraft/skin/fx/test/Test.java @@ -1,7 +1,5 @@ package moe.mickey.minecraft.skin.fx.test; -import java.util.function.Consumer; - import javafx.application.Application; import javafx.scene.Scene; import javafx.stage.Stage; @@ -11,12 +9,14 @@ import moe.mickey.minecraft.skin.fx.SkinCanvasSupport; import moe.mickey.minecraft.skin.fx.animation.SkinAniRunning; import moe.mickey.minecraft.skin.fx.animation.SkinAniWavingArms; +import java.util.function.Consumer; + public class Test extends Application { public static final String TITLE = "FX - Minecraft skin preview"; public static SkinCanvas createSkinCanvas() { - SkinCanvas canvas = new SkinCanvas(SkinCanvas.CHOCOLATE, 400, 400, true); + SkinCanvas canvas = new SkinCanvas(SkinCanvas.STEVE, 400, 400, true); canvas.getAnimationplayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); FunctionHelper.alwaysB(Consumer::accept, canvas, new SkinCanvasSupport.Mouse(.5), new SkinCanvasSupport.Drag(TITLE)); return canvas; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index b8180356a..946bee314 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -17,13 +17,14 @@ */ package org.jackhuang.hmcl.auth.offline; +import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactInfo; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.game.Arguments; import org.jackhuang.hmcl.util.StringUtils; @@ -51,13 +52,13 @@ public class OfflineAccount extends Account { private final AuthlibInjectorArtifactProvider downloader; private final String username; private final UUID uuid; - private final Map textures; + private Skin skin; - protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Map textures) { + protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Skin skin) { this.downloader = requireNonNull(downloader); this.username = requireNonNull(username); this.uuid = requireNonNull(uuid); - this.textures = textures; + this.skin = skin; if (StringUtils.isBlank(username)) { throw new IllegalArgumentException("Username cannot be blank"); @@ -79,11 +80,20 @@ public class OfflineAccount extends Account { return username; } + public Skin getSkin() { + return skin; + } + + public void setSkin(Skin skin) { + this.skin = skin; + invalidate(); + } + @Override public AuthInfo logIn() throws AuthenticationException { AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}"); - if (skin != null || cape != null) { + if (skin != null) { CompletableFuture artifactTask = CompletableFuture.supplyAsync(() -> { try { return downloader.getArtifactInfo(); @@ -109,18 +119,14 @@ public class OfflineAccount extends Account { try { YggdrasilServer server = new YggdrasilServer(0); server.start(); - server.addCharacter(new YggdrasilServer.Character(uuid, username, TextureModel.STEVE, - mapOf( - pair(TextureType.SKIN, server.loadTexture(skin)), - pair(TextureType.CAPE, server.loadTexture(cape)) - ))); + server.addCharacter(new YggdrasilServer.Character(uuid, username, skin.load(username).run())); return authInfo.withArguments(new Arguments().addJVMArguments( "-javaagent:" + artifact.getLocation().toString() + "=" + "http://localhost:" + server.getListeningPort(), "-Dauthlibinjector.side=client" )) .withCloseable(server::stop); - } catch (IOException e) { + } catch (Exception e) { throw new AuthenticationException(e); } } else { @@ -143,18 +149,20 @@ public class OfflineAccount extends Account { return mapOf( pair("uuid", UUIDTypeAdapter.fromUUID(uuid)), pair("username", username), - pair("skin", skin), - pair("cape", cape) + pair("skin", skin.toStorage()) ); } + @Override + public ObjectBinding>> getTextures() { + return super.getTextures(); + } + @Override public String toString() { return new ToStringBuilder(this) .append("username", username) .append("uuid", uuid) - .append("skin", skin) - .append("cape", cape) .toString(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java index 135badc77..e06dbdbb9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java @@ -45,25 +45,23 @@ public final class OfflineAccountFactory extends AccountFactory } public OfflineAccount create(String username, UUID uuid) { - return new OfflineAccount(downloader, username, uuid, null, null); + return new OfflineAccount(downloader, username, uuid, null); } @Override public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) { AdditionalData data; UUID uuid; - String skin; - String cape; + Skin skin; if (additionalData != null) { data = (AdditionalData) additionalData; uuid = data.uuid == null ? getUUIDFromUserName(username) : data.uuid; skin = data.skin; - cape = data.cape; } else { uuid = getUUIDFromUserName(username); - skin = cape = null; + skin = null; } - return new OfflineAccount(downloader, username, uuid, skin, cape); + return new OfflineAccount(downloader, username, uuid, skin); } @Override @@ -73,10 +71,9 @@ public final class OfflineAccountFactory extends AccountFactory UUID uuid = tryCast(storage.get("uuid"), String.class) .map(UUIDTypeAdapter::fromString) .orElse(getUUIDFromUserName(username)); - String skin = tryCast(storage.get("skin"), String.class).orElse(null); - String cape = tryCast(storage.get("cape"), String.class).orElse(null); + Skin skin = Skin.fromStorage(tryCast(storage.get("skin"), Map.class).orElse(null)); - return new OfflineAccount(downloader, username, uuid, skin, cape); + return new OfflineAccount(downloader, username, uuid, skin); } public static UUID getUUIDFromUserName(String username) { @@ -85,13 +82,11 @@ public final class OfflineAccountFactory extends AccountFactory public static class AdditionalData { private final UUID uuid; - private final String skin; - private final String cape; + private final Skin skin; - public AdditionalData(UUID uuid, String skin, String cape) { + public AdditionalData(UUID uuid, Skin skin) { this.uuid = uuid; this.skin = skin; - this.cape = cape; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java index 169d09041..6a641ea76 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java @@ -18,26 +18,31 @@ package org.jackhuang.hmcl.auth.offline; import com.google.gson.annotations.SerializedName; -import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.task.FetchTask; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.Nullable; -import java.io.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.net.URL; import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Collections; +import java.util.Locale; import java.util.Map; +import java.util.Optional; +import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.tryCast; +import static org.jackhuang.hmcl.util.Pair.pair; public class Skin { @@ -46,33 +51,81 @@ public class Skin { STEVE, ALEX, LOCAL_FILE, + LITTLE_SKIN, CUSTOM_SKIN_LOADER_API, - YGGDRASIL_API + YGGDRASIL_API; + + public static Type fromStorage(String type) { + switch (type) { + case "default": + return DEFAULT; + case "steve": + return STEVE; + case "alex": + return ALEX; + case "local_file": + return LOCAL_FILE; + case "little_skin": + return LITTLE_SKIN; + case "custom_skin_loader_api": + return CUSTOM_SKIN_LOADER_API; + case "yggdrasil_api": + return YGGDRASIL_API; + default: + return null; + } + } } - private Type type; - private String value; + private final Type type; + private final String cslApi; + private final String localSkinPath; + private final String localCapePath; + + public Skin(Type type, String cslApi, String localSkinPath, String localCapePath) { + this.type = type; + this.cslApi = cslApi; + this.localSkinPath = localSkinPath; + this.localCapePath = localCapePath; + } public Type getType() { return type; } - public String getValue() { - return value; + public String getCslApi() { + return cslApi; } - public Task toTexture(String username) { + public String getLocalSkinPath() { + return localSkinPath; + } + + public String getLocalCapePath() { + return localCapePath; + } + + public Task load(String username) { switch (type) { case DEFAULT: return Task.supplyAsync(() -> null); case STEVE: - return Task.supplyAsync(() -> Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/steve.png"))); + return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/steve.png")), null)); case ALEX: - return Task.supplyAsync(() -> Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/alex.png"))); + return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/alex.png")), null)); case LOCAL_FILE: - return Task.supplyAsync(() -> Texture.loadTexture(Files.newInputStream(Paths.get(value)))); + return Task.supplyAsync(() -> { + Texture skin = null, cape = null; + Optional skinPath = FileUtils.tryGetPath(localSkinPath); + Optional capePath = FileUtils.tryGetPath(localCapePath); + if (skinPath.isPresent()) skin = Texture.loadTexture(Files.newInputStream(skinPath.get())); + if (capePath.isPresent()) cape = Texture.loadTexture(Files.newInputStream(capePath.get())); + return new LoadedSkin(TextureModel.STEVE, skin, cape); + }); + case LITTLE_SKIN: case CUSTOM_SKIN_LOADER_API: - return Task.composeAsync(() -> new GetTask(new URL(String.format("%s/%s.json", value, username)))) + String realCslApi = type == Type.LITTLE_SKIN ? "http://mcskin.littleservice.cn" : cslApi; + return Task.composeAsync(() -> new GetTask(new URL(String.format("%s/%s.json", realCslApi, username)))) .thenComposeAsync(json -> { SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class); @@ -80,13 +133,57 @@ public class Skin { return Task.supplyAsync(() -> null); } - return new FetchBytesTask(new URL(String.format("%s/textures/%s", value, result.getHash())), 3); - }).thenApplyAsync(Texture::loadTexture); + return Task.allOf( + Task.supplyAsync(result::getModel), + result.getHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(new URL(String.format("%s/textures/%s", realCslApi, result.getHash())), 3), + result.getCapeHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(new URL(String.format("%s/textures/%s", realCslApi, result.getCapeHash())), 3) + ); + }).thenApplyAsync(result -> { + if (result == null) { + return null; + } + + Texture skin, cape; + if (result.get(1) != null) { + skin = Texture.loadTexture((InputStream) result.get(1)); + } else { + skin = null; + } + + if (result.get(2) != null) { + cape = Texture.loadTexture((InputStream) result.get(2)); + } else { + cape = null; + } + + return new LoadedSkin((TextureModel) result.get(0), skin, cape); + }); default: throw new UnsupportedOperationException(); } } + public Map toStorage() { + return mapOf( + pair("type", type.name().toLowerCase(Locale.ROOT)), + pair("cslApi", cslApi), + pair("localSkinPath", localSkinPath), + pair("localCapePath", localCapePath) + ); + } + + public static Skin fromStorage(Map storage) { + if (storage == null) return null; + + Type type = tryCast(storage.get("type"), String.class).flatMap(t -> Optional.ofNullable(Type.fromStorage(t))) + .orElse(Type.DEFAULT); + String cslApi = tryCast(storage.get("cslApi"), String.class).orElse(null); + String localSkinPath = tryCast(storage.get("localSkinPath"), String.class).orElse(null); + String localCapePath = tryCast(storage.get("localCapePath"), String.class).orElse(null); + + return new Skin(type, cslApi, localSkinPath, localCapePath); + } + private static class FetchBytesTask extends FetchTask { public FetchBytesTask(URL url, int retry) { @@ -127,6 +224,30 @@ public class Skin { } } + public static class LoadedSkin { + private final TextureModel model; + private final Texture skin; + private final Texture cape; + + public LoadedSkin(TextureModel model, Texture skin, Texture cape) { + this.model = model; + this.skin = skin; + this.cape = cape; + } + + public TextureModel getModel() { + return model; + } + + public Texture getSkin() { + return skin; + } + + public Texture getCape() { + return cape; + } + } + private static class SkinJson { private final String username; private final String skin; @@ -183,6 +304,12 @@ public class Skin { return null; } + public String getCapeHash() { + if (textures != null && textures.cape != null) { + return textures.cape; + } else return cape; + } + public static class TextureJson { @SerializedName("default") private final String defaultSkin; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java index 807655674..761fab52a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java @@ -42,6 +42,10 @@ public class Texture { this.data = requireNonNull(data); } + public byte[] getData() { + return data; + } + public String getHash() { return hash; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java index 2da246cda..cfe90ca08 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java @@ -19,8 +19,6 @@ package org.jackhuang.hmcl.auth.offline; import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.util.KeyUtils; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -36,7 +34,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; @@ -143,14 +140,12 @@ public class YggdrasilServer extends HttpServer { public static class Character { private final UUID uuid; private final String name; - private final TextureModel model; - private final Map textures; + private final Skin.LoadedSkin skin; - public Character(UUID uuid, String name, TextureModel model, Map textures) { + public Character(UUID uuid, String name, Skin.LoadedSkin skin) { this.uuid = uuid; this.name = name; - this.model = model; - this.textures = textures; + this.skin = Objects.requireNonNull(skin); } public UUID getUUID() { @@ -161,30 +156,17 @@ public class YggdrasilServer extends HttpServer { return name; } - public TextureModel getModel() { - return model; - } - - public Map getTextures() { - return textures; - } - - private Map createKeyValue(String key, String value) { - return mapOf( - pair("name", key), - pair("value", value) - ); - } - public GameProfile toSimpleResponse() { return new GameProfile(uuid, name); } public Object toCompleteResponse(String rootUrl) { Map realTextures = new HashMap<>(); - for (Map.Entry textureEntry : textures.entrySet()) { - if (textureEntry.getValue() == null) continue; - realTextures.put(textureEntry.getKey().name(), mapOf(pair("url", rootUrl + "/textures/" + textureEntry.getValue().getHash()))); + if (skin.getSkin() != null) { + realTextures.put("SKIN", mapOf(pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()))); + } + if (skin.getCape() != null) { + realTextures.put("CAPE", mapOf(pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()))); } Map textureResponse = mapOf( diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/Task.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/Task.java index d02bd87d1..7423692d6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/Task.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/Task.java @@ -340,7 +340,7 @@ public abstract class Task { messageUpdate.accept(newMessage); } - public final void run() throws Exception { + public final T run() throws Exception { if (getSignificance().shouldLog()) Logging.LOG.log(Level.FINE, "Executing task: " + getName()); @@ -350,6 +350,8 @@ public abstract class Task { for (Task task : getDependencies()) doSubTask(task); onDone.fireEvent(new TaskEvent(this, this, false)); + + return getResult(); } private void doSubTask(Task task) throws Exception { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 8a75b1ec6..06debffc0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -413,6 +413,7 @@ public final class FileUtils { } public static Optional tryGetPath(String first, String... more) { + if (first == null) return Optional.empty(); try { return Optional.of(Paths.get(first, more)); } catch (InvalidPathException e) {