diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java index a556242b17..7b3c49ea76 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java @@ -30,6 +30,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; +import javafx.scene.text.TextFlow; import org.jackhuang.hmcl.ui.construct.ComponentList; import org.jackhuang.hmcl.ui.construct.SpinnerPane; @@ -99,6 +100,12 @@ public static JFXButton createDecoratorButton(String tooltip, SVG svg, Runnable return ret; } + public static TextFlow createTip() { + TextFlow textFlow = new TextFlow(); + textFlow.getStyleClass().add("list-tip"); + return textFlow; + } + protected abstract List initializeToolbar(P skinnable); protected ListCell createListCell(JFXListView listView) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java index 472758bd38..642bdc5ab0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java @@ -29,6 +29,7 @@ public abstract class MDListCell extends ListCell { private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); private final StackPane container = new StackPane(); + private final RipplerContainer ripplerContainer; private final StackPane root = new StackPane(); public MDListCell(JFXListView listView) { @@ -37,7 +38,7 @@ public MDListCell(JFXListView listView) { setGraphic(null); root.getStyleClass().add("md-list-cell"); - RipplerContainer ripplerContainer = new RipplerContainer(container); + this.ripplerContainer = new RipplerContainer(container); root.getChildren().setAll(ripplerContainer); Region clippedContainer = (Region) listView.lookup(".clipped-container"); @@ -73,5 +74,9 @@ protected void setSelectable() { }); } + protected void onClicked(Runnable action) { + FXUtils.onClicked(ripplerContainer, action); + } + protected abstract void updateControl(T item, boolean empty); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTFileType.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTFileType.java index ceab275040..6d9eb02ceb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTFileType.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTFileType.java @@ -41,7 +41,7 @@ * @author Glavo */ public enum NBTFileType { - COMPRESSED("dat", "dat_old") { + COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") { @Override public Tag read(Path file) throws IOException { try (BufferedInputStream fileInputStream = new BufferedInputStream(Files.newInputStream(file))) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java index c0f668be6e..48f794cd8a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java @@ -153,6 +153,10 @@ public void selectVersion(String versionID) { FXUtils.runInFX(() -> selectedVersion.set(versionID)); } + public DownloadPage.DownloadCallback getCallback() { + return callback; + } + private void search(String userGameVersion, RemoteModRepository.Category category, int pageOffset, String searchFilter, RemoteModRepository.SortType sort) { retrySearch = null; setLoading(true); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 998e3b5263..0a44092cbf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -57,7 +57,6 @@ public final class ModListPage extends ListPageBase supportedLoaders = EnumSet.noneOf(ModLoaderType.class); @@ -90,7 +89,7 @@ public void loadVersion(Profile profile, String id) { HMCLGameRepository repository = profile.getRepository(); Version resolved = repository.getResolvedPreservingPatchesVersion(id); - this.gameVersion = repository.getGameVersion(resolved).orElse(null); + String gameVersion = repository.getGameVersion(resolved).orElse(null); LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolved, gameVersion); modded.set(analyzer.hasModLoader()); loadMods(profile.getRepository().getModManager(id)); @@ -111,7 +110,8 @@ private void loadMods(ModManager modManager) { lock.unlock(); } }, Schedulers.io()).whenCompleteAsync((list, exception) -> { - updateSupportedLoaders(modManager); + supportedLoaders.clear(); + supportedLoaders.addAll(modManager.getSupportedLoaders()); if (exception == null) { getItems().setAll(list); @@ -123,51 +123,6 @@ private void loadMods(ModManager modManager) { }, Schedulers.javafx()); } - private void updateSupportedLoaders(ModManager modManager) { - supportedLoaders.clear(); - - LibraryAnalyzer analyzer = modManager.getLibraryAnalyzer(); - if (analyzer == null) { - Collections.addAll(supportedLoaders, ModLoaderType.values()); - return; - } - - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { - if (type.isModLoader() && analyzer.has(type)) { - ModLoaderType modLoaderType = type.getModLoaderType(); - if (modLoaderType != null) { - supportedLoaders.add(modLoaderType); - - if (modLoaderType == ModLoaderType.CLEANROOM) - supportedLoaders.add(ModLoaderType.FORGE); - } - } - } - - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { - supportedLoaders.add(ModLoaderType.FORGE); - } - - if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { - supportedLoaders.add(ModLoaderType.FABRIC); - } - - if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { - supportedLoaders.add(ModLoaderType.FABRIC); - } - - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { - supportedLoaders.add(ModLoaderType.FORGE); - supportedLoaders.add(ModLoaderType.NEO_FORGED); - } - - // Sinytra Connector - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && modManager.hasMod("connectormod", ModLoaderType.NEO_FORGED) - || "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) { - supportedLoaders.add(ModLoaderType.FABRIC); - } - } - public void add() { FileChooser chooser = new FileChooser(); chooser.setTitle(i18n("mods.add.title")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java index 30d769f8de..3e849577b7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java @@ -20,6 +20,11 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXListView; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -27,20 +32,30 @@ import javafx.scene.image.Image; import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; +import javafx.scene.text.Text; +import org.jackhuang.hmcl.mod.ModLoaderType; +import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; import org.jackhuang.hmcl.schematic.LitematicFile; +import org.jackhuang.hmcl.schematic.Schematic; +import org.jackhuang.hmcl.schematic.SchematicType; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.ui.nbt.NBTEditorPage; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -52,7 +67,9 @@ import java.util.*; import java.util.stream.Stream; -import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.ui.FXUtils.*; +import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createTip; +import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -61,6 +78,9 @@ */ public final class SchematicsPage extends ListPageBase implements VersionPage.VersionLoadable { + private static volatile RemoteMod litematica; + private static volatile RemoteMod forgematica; + private static String translateAuthorName(String author) { if (I18n.isUseChinese() && "hsds".equals(author)) { return "黑山大叔"; @@ -68,65 +88,145 @@ private static String translateAuthorName(String author) { return author; } + private static String translateType(SchematicType type) { + return i18n("schematics.info.type." + type.name().toLowerCase(Locale.ROOT)); + } + + private Profile profile; + private String instanceId; private Path schematicsDirectory; - private DirItem currentDirectory; + private final ObjectProperty currentDirectory = new SimpleObjectProperty<>(this, "currentDirectory", null); + private final ObjectProperty warningTip = new SimpleObjectProperty<>(this, "tip", null); + + private final BooleanProperty isRootProperty = new SimpleBooleanProperty(this, "isRoot", true); public SchematicsPage() { FXUtils.applyDragListener(this, - file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"), + file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file), this::addFiles ); } @Override protected Skin createDefaultSkin() { - return new SchematicsPageSkin(); + return new SchematicsPageSkin(this); } @Override public void loadVersion(Profile profile, String version) { + this.profile = profile; + this.instanceId = version; this.schematicsDirectory = profile.getRepository().getSchematicsDirectory(version); refresh(); } + private ObjectProperty currentDirectoryProperty() { + return currentDirectory; + } + + public BooleanProperty isRootProperty() { + return isRootProperty; + } + + public void navigateBack() { + var d = currentDirectoryProperty().get(); + if (d == null) return; + if (d.parent != null) navigateTo(d.parent); + } + public void refresh() { - Path schematicsDirectory = this.schematicsDirectory; if (schematicsDirectory == null) return; setLoading(true); - Task.supplyAsync(() -> loadAll(schematicsDirectory, null)) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - setLoading(false); - if (exception == null) { - DirItem target = result; - if (currentDirectory != null) { - loop: - for (int i = 0; i < currentDirectory.relativePath.size(); i++) { - String dirName = currentDirectory.relativePath.get(i); - - for (Item child : target.children) { - if (child instanceof DirItem && child.getName().equals(dirName)) { - target = (DirItem) child; - continue loop; - } - } - break; - } + Task.supplyAsync(Schedulers.io(), () -> { + var litematicaState = LitematicaState.NOT_INSTALLED; + var modManager = profile.getRepository().getModManager(instanceId); + boolean shouldUseForgematica = false; + try { + modManager.refreshMods(); + var mods = modManager.getMods(); + for (var localModFile : mods) { + if ("litematica".equals(localModFile.getId()) || "forgematica".equals(localModFile.getId())) { + if (localModFile.isActive()) { + litematicaState = LitematicaState.OK; + break; + } else { + litematicaState = LitematicaState.DISABLED; + } + } + } + if (litematicaState == LitematicaState.NOT_INSTALLED && modManager.getLibraryAnalyzer() != null) { + var modLoaders = modManager.getLibraryAnalyzer().getModLoaders(); + shouldUseForgematica = (modLoaders.contains(ModLoaderType.FORGE) || modLoaders.contains(ModLoaderType.NEO_FORGED)) + && GameVersionNumber.asGameVersion(Optional.ofNullable(modManager.getGameVersion())).isAtLeast("1.16.4", "20w45a"); + if (litematica == null && !shouldUseForgematica) { + try { + litematica = ModrinthRemoteModRepository.MODS.getModById("litematica"); + } catch (IOException ignored) { + } + } else if (forgematica == null && shouldUseForgematica) { + try { + forgematica = ModrinthRemoteModRepository.MODS.getModById("forgematica"); + } catch (IOException ignored) { } - - navigateTo(target); - } else { - LOG.warning("Failed to load schematics", exception); } - }).start(); + } + } catch (IOException e) { + LOG.warning("Failed to load mods, unable to check litematica", e); + } + DirItem target = loadRoot(schematicsDirectory); + if (currentDirectoryProperty().get() != null) { + loop: + for (String dirName : currentDirectoryProperty().get().relativePath) { + target.preLoad(); + for (var dirChild : target.dirChildren) { + if (dirChild.getName().equals(dirName)) { + target = dirChild; + continue loop; + } + } + break; + } + } + return new LoadResult(litematicaState, shouldUseForgematica, target); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + switch (result.state()) { + case NOT_INSTALLED -> { + boolean useForgematica = forgematica != null && result.shouldUseForgematica(); + boolean useLitematica = litematica != null && !result.shouldUseForgematica(); + if (useForgematica || useLitematica) { + warningTip.set(new WarningTip(i18n("schematics.warning.no_litematica_install"), () -> { + var modDownloads = Controllers.getDownloadPage().showModDownloads(); + modDownloads.selectVersion(instanceId); + Controllers.navigate(new DownloadPage( + modDownloads, + useForgematica ? forgematica : litematica, + modDownloads.getProfileVersion(), + modDownloads.getCallback()) + ); + })); + } else { + warningTip.set(new WarningTip(i18n("schematics.warning.no_litematica"), null)); + } + } + case DISABLED -> warningTip.set(new WarningTip(i18n("schematics.warning.litematica_disabled"), null)); + default -> warningTip.set(new WarningTip(null, null)); + } + navigateTo(result.targetDir()); + } else { + LOG.warning("Failed to load schematics", exception); + } + setLoading(false); + }).start(); } public void addFiles(List files) { - if (currentDirectory == null) + if (currentDirectoryProperty().get() == null) return; - Path dir = currentDirectory.path; + Path dir = currentDirectoryProperty().get().getPath(); try { // Can be executed in the background, but be careful that users can call loadVersion during this time Files.createDirectories(dir); @@ -153,10 +253,9 @@ public void onAddFiles() { } public void onCreateDirectory() { - if (currentDirectory == null) - return; + if (currentDirectoryProperty().get() == null) return; - Path parent = currentDirectory.path; + Path parent = currentDirectoryProperty().get().getPath(); Controllers.dialog(new InputDialogPane( i18n("schematics.create_directory.prompt"), "", @@ -188,37 +287,29 @@ public void onCreateDirectory() { })); } - private DirItem loadAll(Path dir, @Nullable DirItem parent) { - DirItem item = new DirItem(dir, parent); - - try (Stream stream = Files.list(dir)) { - for (Path path : Lang.toIterable(stream)) { - if (Files.isDirectory(path)) { - item.children.add(loadAll(path, item)); - } else if (path.getFileName().toString().endsWith(".litematic") && Files.isRegularFile(path)) { - try { - item.children.add(new LitematicFileItem(LitematicFile.load(path))); - } catch (IOException e) { - LOG.warning("Failed to load litematic file: " + path, e); - } - } - } - } catch (NoSuchFileException ignored) { - } catch (IOException e) { - LOG.warning("Failed to load schematics in " + dir, e); - } + public void onRevealSchematicsFolder() { + var d = currentDirectoryProperty().get(); + var p = d != null ? d.getPath() : schematicsDirectory; + if (p != null) FXUtils.openFolder(p); + } - item.children.sort(Comparator.naturalOrder()); + private DirItem loadRoot(Path dir) { + var item = new DirItem(dir, null); + item.load(); return item; } private void navigateTo(DirItem item) { - currentDirectory = item; - getItems().clear(); - if (item.parent != null) { - getItems().add(new BackItem(item.parent)); - } - getItems().addAll(item.children); + if (currentDirectoryProperty().get() == item) return; + currentDirectoryProperty().set(item); + isRootProperty().set(item.parent == null); + setLoading(true); + Task.runAsync(item::load).whenComplete(Schedulers.javafx(), exception -> { + if (currentDirectoryProperty().get() == item) { + getItems().setAll(item.children); + setLoading(false); + } + }).start(); } abstract sealed class Item implements Comparable { @@ -260,60 +351,15 @@ public int compareTo(@NotNull SchematicsPage.Item o) { } } - private final class BackItem extends Item { - - private final DirItem parent; - - BackItem(DirItem parent) { - this.parent = parent; - } - - @Override - int order() { - return 0; - } - - @Override - Path getPath() { - return null; - } - - @Override - String getName() { - return ".."; - } - - @Override - String getDescription() { - return i18n("schematics.back_to", parent.getName()); - } - - @Override - SVG getIcon() { - return SVG.FOLDER; - } - - @Override - void onClick() { - navigateTo(parent); - } - - @Override - void onReveal() { - throw new UnsupportedOperationException("Unreachable"); - } - - @Override - void onDelete() { - throw new UnsupportedOperationException("Unreachable"); - } - } - private final class DirItem extends Item { final Path path; final @Nullable DirItem parent; final List children = new ArrayList<>(); + final List dirChildren = new ArrayList<>(); final List relativePath; + int size = 0; + boolean preLoaded = false; + boolean loaded = false; DirItem(Path path, @Nullable DirItem parent) { this.path = path; @@ -321,7 +367,7 @@ private final class DirItem extends Item { if (parent != null) { this.relativePath = new ArrayList<>(parent.relativePath); - relativePath.add(path.getFileName().toString()); + relativePath.add(FileUtils.getName(path)); } else { this.relativePath = Collections.emptyList(); } @@ -329,7 +375,7 @@ private final class DirItem extends Item { @Override int order() { - return 1; + return 0; } @Override @@ -339,12 +385,12 @@ Path getPath() { @Override public String getName() { - return path.getFileName().toString(); + return FileUtils.getName(path); } @Override String getDescription() { - return i18n("schematics.sub_items", children.size()); + return i18n("schematics.sub_items", size); } @Override @@ -352,6 +398,50 @@ SVG getIcon() { return SVG.FOLDER; } + void preLoad() throws IOException { + if (this.preLoaded) return; + try (Stream stream = Files.list(path)) { + stream.forEach(p -> { + boolean b1 = Files.isDirectory(p); + boolean b2 = Schematic.isFileSchematic(p); + if (b1 || b2) this.size++; + if (b1) { + var child = new DirItem(p, this); + this.dirChildren.add(child); + } + }); + } + this.preLoaded = true; + } + + void load() { + if (this.loaded) return; + + try { + preLoad(); + try (Stream stream = Files.list(path)) { + for (var dir : dirChildren) { + dir.preLoad(); + this.children.add(dir); + } + stream.filter(Schematic::isFileSchematic) + .forEach(p -> { + try { + this.children.add(new SchematicItem(p)); + } catch (IOException e) { + LOG.warning("Failed to load schematic file: " + p, e); + } + }); + } + } catch (NoSuchFileException ignored) { + } catch (IOException e) { + LOG.warning("Failed to load schematics in " + path, e); + } + + this.children.sort(Comparator.naturalOrder()); + this.loaded = true; + } + @Override void onClick() { navigateTo(this); @@ -365,8 +455,7 @@ void onReveal() { @Override void onDelete() { try { - FileUtils.cleanDirectory(path); - Files.deleteIfExists(path); + FileUtils.deleteDirectory(path); refresh(); } catch (IOException e) { LOG.warning("Failed to delete directory: " + path, e); @@ -374,19 +463,27 @@ void onDelete() { } } - private final class LitematicFileItem extends Item { - final LitematicFile file; + private final class SchematicItem extends Item { + final Path path; + final Schematic file; final String name; final Image image; - private LitematicFileItem(LitematicFile file) { - this.file = file; + private SchematicItem(Path path) throws IOException { + this.path = path; + this.file = Schematic.load(path); + + if (file == null) throw new IOException("Unsupported or deleted file: " + path); - String name = file.getName(); - if (name != null && !"Unnamed".equals(name)) { - this.name = name; + if (this.file instanceof LitematicFile lFile) { + String name = lFile.getName(); + if (StringUtils.isNotBlank(name) && !"Unnamed".equals(name)) { + this.name = name; + } else { + this.name = FileUtils.getNameWithoutExtension(path); + } } else { - this.name = StringUtils.removeSuffix(file.getFile().getFileName().toString(), ".litematic"); + this.name = FileUtils.getNameWithoutExtension(path); } WritableImage image = null; @@ -410,12 +507,12 @@ private LitematicFileItem(LitematicFile file) { @Override int order() { - return 2; + return 1; } @Override Path getPath() { - return file.getFile(); + return path; } @Override @@ -425,7 +522,7 @@ public String getName() { @Override String getDescription() { - return file.getFile().getFileName().toString(); + return path.getFileName().toString(); } @Override @@ -449,25 +546,25 @@ Node getIcon(int size) { @Override void onClick() { - Controllers.dialog(new LitematicInfoDialog()); + Controllers.dialog(new SchematicInfoDialog()); } @Override void onReveal() { - FXUtils.showFileInExplorer(file.getFile()); + FXUtils.showFileInExplorer(path); } @Override void onDelete() { try { - Files.deleteIfExists(file.getFile()); + Files.deleteIfExists(path); refresh(); } catch (IOException e) { - LOG.warning("Failed to delete litematic file: " + file.getFile(), e); + LOG.warning("Failed to delete schematic file: " + path, e); } } - private final class LitematicInfoDialog extends JFXDialogLayout { + private final class SchematicInfoDialog extends JFXDialogLayout { private final ComponentList details; private void addDetailItem(String key, Object detail) { @@ -477,38 +574,45 @@ private void addDetailItem(String key, Object detail) { details.getContent().add(borderPane); } - private void updateContent(LitematicFile file) { + private void updateContent(Schematic file) { details.getContent().clear(); addDetailItem(i18n("schematics.info.name"), file.getName()); + addDetailItem(i18n("schematics.info.type"), translateType(file.getType())); if (StringUtils.isNotBlank(file.getAuthor())) addDetailItem(i18n("schematics.info.schematic_author"), translateAuthorName(file.getAuthor())); if (file.getTimeCreated() != null) addDetailItem(i18n("schematics.info.time_created"), I18n.formatDateTime(file.getTimeCreated())); if (file.getTimeModified() != null && !file.getTimeModified().equals(file.getTimeCreated())) addDetailItem(i18n("schematics.info.time_modified"), I18n.formatDateTime(file.getTimeModified())); - if (file.getRegionCount() > 0) - addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount())); - if (file.getTotalVolume() > 0) - addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume()); - if (file.getTotalBlocks() > 0) - addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks()); + if (file.getRegionCount().isPresent()) + addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount().getAsInt())); + if (file.getTotalVolume().isPresent()) + addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume().getAsInt()); + if (file.getTotalBlocks().isPresent()) + addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks().getAsInt()); if (file.getEnclosingSize() != null) addDetailItem(i18n("schematics.info.enclosing_size"), - String.format("%d x %d x %d", (int) file.getEnclosingSize().getX(), - (int) file.getEnclosingSize().getY(), - (int) file.getEnclosingSize().getZ())); - - addDetailItem(i18n("schematics.info.version"), file.getVersion()); + String.format("%d x %d x %d", file.getEnclosingSize().x(), + file.getEnclosingSize().y(), + file.getEnclosingSize().z())); + if (StringUtils.isNotBlank(file.getMinecraftVersion())) + addDetailItem(i18n("schematics.info.mc_data_version"), file.getMinecraftVersion()); + if (file.getVersion().isPresent()) + addDetailItem(i18n("schematics.info.version"), + file.getSubVersion().isPresent() + ? "%d.%d".formatted(file.getVersion().getAsInt(), file.getSubVersion().getAsInt()) + : file.getVersion().getAsInt() + ); } - LitematicInfoDialog() { + SchematicInfoDialog() { HBox titleBox = new HBox(8); { Node icon = getIcon(40); TwoLineListItem title = new TwoLineListItem(); title.setTitle(getName()); - title.setSubtitle(file.getFile().getFileName().toString()); + title.setSubtitle(path.getFileName().toString()); titleBox.getChildren().setAll(icon, title); setHeading(titleBox); @@ -516,10 +620,15 @@ private void updateContent(LitematicFile file) { { this.details = new ComponentList(); + details.setStyle("-fx-effect: null;"); StackPane detailsContainer = new StackPane(); - detailsContainer.setPadding(new Insets(10, 0, 0, 0)); detailsContainer.getChildren().add(details); - setBody(detailsContainer); + ScrollPane scrollPane = new ScrollPane(detailsContainer); + scrollPane.setFitToWidth(true); + FXUtils.setOverflowHidden(scrollPane, 8); + FXUtils.smoothScrolling(scrollPane); + StackPane.setMargin(scrollPane, new Insets(10, 0, 0, 0)); + setBody(scrollPane); } { @@ -532,15 +641,16 @@ private void updateContent(LitematicFile file) { onEscPressed(this, okButton::fire); } + this.prefWidthProperty().bind(Controllers.getStage().widthProperty().multiply(0.6)); + this.maxHeightProperty().bind(Controllers.getStage().heightProperty().multiply(0.8)); + updateContent(file); } } } - private static final class Cell extends ListCell { + private static final class Cell extends MDListCell { - private final RipplerContainer graphics; - private final BorderPane root; private final StackPane left; private final TwoLineListItem center; private final HBox right; @@ -548,12 +658,17 @@ private static final class Cell extends ListCell { private final ImageContainer iconImageView; private final SVGContainer iconSVGView; + private final BooleanProperty isFileProperty = new SimpleBooleanProperty(this, "isFile", false); + private final BooleanProperty isDirectoryProperty = new SimpleBooleanProperty(this, "isDirectory", false); + private final Tooltip tooltip = new Tooltip(); - public Cell() { - this.root = new BorderPane(); - root.getStyleClass().add("md-list-cell"); - root.setPadding(new Insets(8)); + public Cell(JFXListView listView) { + super(listView); + + var box = new HBox(8); + box.setAlignment(Pos.CENTER_LEFT); + box.setPickOnBounds(false); { this.left = new StackPane(); @@ -561,103 +676,190 @@ public Cell() { this.iconImageView = new ImageContainer(32); this.iconSVGView = new SVGContainer(32); - - BorderPane.setAlignment(left, Pos.CENTER); - root.setLeft(left); } - { this.center = new TwoLineListItem(); - root.setCenter(center); + HBox.setHgrow(center, Priority.ALWAYS); } - { this.right = new HBox(8); right.setAlignment(Pos.CENTER_RIGHT); JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN); FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); + { + var fo = SVG.FOLDER_OPEN.createIcon(); + var f = SVG.FOLDER.createIcon(); + btnReveal.graphicProperty().bind(isDirectoryProperty.map(b -> b ? fo : f)); + } btnReveal.setOnAction(event -> { Item item = getItem(); - if (item != null && !(item instanceof BackItem)) - item.onReveal(); + if (item != null) item.onReveal(); + }); + + JFXButton btnExplore = FXUtils.newToggleButton4(SVG.EXPLORE); // Change the icon if allows editing + btnExplore.setOnAction(event -> { + Item item = getItem(); + if (item instanceof SchematicItem) { + try { + Controllers.navigate(new NBTEditorPage(item.getPath())); + } catch (IOException ignored) { // Should be impossible + } + } }); + btnExplore.visibleProperty().bind(isFileProperty); JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER); btnDelete.setOnAction(event -> { Item item = getItem(); - if (item != null && !(item instanceof BackItem)) { + if (item != null) { Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), item::onDelete, null); } }); - right.getChildren().setAll(btnReveal, btnDelete); + right.getChildren().setAll(btnExplore, btnReveal, btnDelete); } - this.graphics = new RipplerContainer(root); - FXUtils.onClicked(graphics, () -> { - Item item = getItem(); - if (item != null) - item.onClick(); + box.getChildren().setAll(left, center, right); + StackPane.setMargin(box, new Insets(8)); + getContainer().getChildren().setAll(box); + onClicked(() -> { + var item = getItem(); + if (item != null) item.onClick(); }); } @Override - protected void updateItem(Item item, boolean empty) { - super.updateItem(item, empty); + protected void updateControl(Item item, boolean empty) { + if (empty || item == null) return; iconImageView.setImage(null); - if (empty || item == null) { - setGraphic(null); - center.setTitle(""); - center.setSubtitle(""); + isFileProperty.set(item instanceof SchematicItem); + isDirectoryProperty.set(item.isDirectory()); + + if (item instanceof SchematicItem fileItem && fileItem.getImage() != null) { + iconImageView.setImage(fileItem.getImage()); + left.getChildren().setAll(iconImageView); } else { - if (item instanceof LitematicFileItem fileItem && fileItem.getImage() != null) { - iconImageView.setImage(fileItem.getImage()); - left.getChildren().setAll(iconImageView); - } else { - iconSVGView.setIcon(item.getIcon()); - left.getChildren().setAll(iconSVGView); - } + iconSVGView.setIcon(item.getIcon()); + left.getChildren().setAll(iconSVGView); + } - center.setTitle(item.getName()); - center.setSubtitle(item.getDescription()); + center.setTitle(item.getName()); + center.setSubtitle(item.getDescription()); - Path path = item.getPath(); - if (path != null) { - tooltip.setText(FileUtils.getAbsolutePath(path)); - FXUtils.installSlowTooltip(left, tooltip); - } else { - tooltip.setText(""); - Tooltip.uninstall(left, tooltip); - } + Path path = item.getPath(); + if (path != null) { + tooltip.setText(FileUtils.getAbsolutePath(path)); + FXUtils.installSlowTooltip(left, tooltip); + } else { + Tooltip.uninstall(left, tooltip); + } + } + } + + private static final class SchematicsPageSkin extends SkinBase { - root.setRight(item instanceof BackItem ? null : right); + private final JFXListView listView; - setGraphic(graphics); + SchematicsPageSkin(SchematicsPage skinnable) { + super(skinnable); + + StackPane pane = new StackPane(); + pane.setPadding(new Insets(10)); + pane.getStyleClass().addAll("notice-pane"); + + ComponentList root = new ComponentList(); + root.getStyleClass().add("no-padding"); + listView = new JFXListView<>(); + listView.setSelectionModel(new NoneMultipleSelectionModel<>()); + + { + var toolbar = new HBox(); + JFXButton btnGoBack = createToolbarButton2("", SVG.ARROW_BACK, skinnable::navigateBack); + btnGoBack.disableProperty().bind(skinnable.isRootProperty()); + toolbar.getChildren().setAll( + btnGoBack, + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), + createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles), + createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory), + createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::onRevealSchematicsFolder) + ); + root.getContent().add(toolbar); + } + + { + var tip = createTip(); + HBox.setMargin(tip, new Insets(5)); + var tipPane = new HBox(tip); + tipPane.setAlignment(Pos.CENTER_LEFT); + FXUtils.onChangeAndOperate(skinnable.warningTip, pair -> { + root.getContent().remove(tipPane); + if (pair != null && !StringUtils.isBlank(pair.message())) { + var txt = new Text(pair.message()); + if (pair.action() != null) FXUtils.onClicked(txt, pair.action()); + tip.getChildren().setAll(txt); + root.getContent().add(1, tipPane); + } + }); } + + { + SpinnerPane center = new SpinnerPane(); + ComponentList.setVgrow(center, Priority.ALWAYS); + center.loadingProperty().bind(skinnable.loadingProperty()); + + listView.setCellFactory(x -> new Cell(listView)); + listView.setSelectionModel(new NoneMultipleSelectionModel<>()); + Bindings.bindContent(listView.getItems(), skinnable.getItems()); + + // ListViewBehavior would consume ESC pressed event, preventing us from handling it + // So we ignore it here + ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); + listView.getStyleClass().add("no-horizontal-scrollbar"); + + + center.setContent(listView); + root.getContent().add(center); + } + + { + var relPath = createTip(); + HBox.setMargin(relPath, new Insets(5)); + FXUtils.onChangeAndOperate(skinnable.currentDirectoryProperty(), currentDir -> { + relPath.getChildren().clear(); + var d = currentDir; + while (d != null) { + relPath.getChildren().add(0, new Text("/")); + var txt = new Text(d.getName()); + var finalD = d; + FXUtils.onClicked(txt, () -> skinnable.navigateTo(finalD)); + relPath.getChildren().add(0, txt); + d = d.parent; + } + }); + var relPathPane = new HBox(relPath); + relPathPane.setAlignment(Pos.CENTER_LEFT); + root.getContent().add(relPathPane); + } + + pane.getChildren().setAll(root); + getChildren().setAll(pane); } } - private final class SchematicsPageSkin extends ToolbarListPageSkin { - SchematicsPageSkin() { - super(SchematicsPage.this); - } + private enum LitematicaState { + DISABLED, + NOT_INSTALLED, + OK + } - @Override - protected List initializeToolbar(SchematicsPage skinnable) { - return Arrays.asList( - createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), - createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles), - createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory) - ); - } + private record LoadResult(LitematicaState state, boolean shouldUseForgematica, DirItem targetDir) { + } - @Override - protected ListCell createListCell(JFXListView listView) { - return new Cell(); - } + private record WarningTip(String message, Runnable action) { } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java index 0184026260..5116812806 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java @@ -181,6 +181,10 @@ private void onBrowse(String sub) { FXUtils.openFolder(getProfile().getRepository().getRunDirectory(getVersion()).resolve(sub)); } + private void onBrowseSchematics() { + FXUtils.openFolder(getProfile().getRepository().getSchematicsDirectory(getVersion())); + } + private void redownloadAssetIndex() { Versions.updateGameAssets(getProfile(), getVersion()); } @@ -275,7 +279,7 @@ protected Skin(VersionPage control) { new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup), new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup), new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup), - new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), () -> control.onBrowse("schematics"), browsePopup), + new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), control::onBrowseSchematics, browsePopup), new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup), new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup), new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup), diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 54708ea303..c205e3c243 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1980,4 +1980,9 @@ .line-toggle-button .jfx-toggle-button { -fx-padding: 0; -} \ No newline at end of file +} + +.list-tip Text { + -fx-font-size: 13; + -fx-fill: -monet-on-surface; +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 4ac702239d..678ce7c10f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1262,7 +1262,6 @@ schematics=Schematics schematics.add=Add schematics.add.failed=Failed to add schematic files schematics.add.title=Choose schematic file you want to import -schematics.back_to=Back to "%s" schematics.create_directory=New Directory schematics.create_directory.prompt=Please enter the new directory name schematics.create_directory.failed=Failed to create directory @@ -1278,8 +1277,16 @@ schematics.info.time_created=Created Time schematics.info.time_modified=Modified Time schematics.info.total_blocks=Total Blocks schematics.info.total_volume=Total Volume +schematics.info.type=Schematic Type +schematics.info.type.litematic=Litematic (Litematica) +schematics.info.type.nbt_structure=NBT Structure (Vanilla) +schematics.info.type.schem=Schem (Schematica, WE, MCEdit) schematics.info.version=Schematic Version +schematics.info.mc_data_version=Minecraft Data Version schematics.manage=Schematics +schematics.warning.litematica_disabled=Litematica or Forgematica has been disabled. +schematics.warning.no_litematica=Litematica or Forgematica not installed. +schematics.warning.no_litematica_install=Litematica or Forgematica not installed. Click to install. schematics.sub_items=%d sub-item(s) search=Search diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 893636044c..5be844f076 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1164,7 +1164,6 @@ reveal.in_file_manager=إظهار في مدير الملفات schematics=المخططات schematics.add.failed=فشل إضافة ملفات المخططات -schematics.back_to=العودة إلى "%s" schematics.create_directory.prompt=يرجى إدخال اسم الدليل الجديد schematics.create_directory.failed=فشل إنشاء الدليل schematics.create_directory.failed.already_exists=الدليل موجود بالفعل diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 2d47c4b7c6..0ce678794a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1174,7 +1174,6 @@ reveal.in_file_manager=Mostrar en el administrador de archivos schematics=Esquemas schematics.add.failed=No se han podido añadir archivos de esquema -schematics.back_to=Atrás a «%s» schematics.create_directory.prompt=Introduzca el nuevo nombre del directorio schematics.create_directory.failed=No se ha podido crear el directorio schematics.create_directory.failed.already_exists=El directorio ya existe diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index efef1268c5..b0da3dda20 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -982,7 +982,6 @@ reveal.in_file_manager=覽於理案臺 schematics=蜃圖 schematics.add=增蜃圖 schematics.add.failed=增蜃圖未成 -schematics.back_to=退至「%s」 schematics.create_directory=增案夾 schematics.create_directory.prompt=書新案夾之名 schematics.create_directory.failed=增案夾未成 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 41b5944cfc..d9aed76d7d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1166,7 +1166,6 @@ reveal.in_file_manager=Открыть в файловый менеджер schematics=Схемы schematics.add.failed=Не удалось добавить файлы схем -schematics.back_to=Назад на «%s» schematics.create_directory.prompt=Введите новое имя папки schematics.create_directory.failed=Не удалось создать папку schematics.create_directory.failed.already_exists=Папка уже существует diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 15626f00e7..04fa3c5105 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1114,7 +1114,6 @@ reveal.in_file_manager=Показати в менеджері файлів schematics=Схематики schematics.add.failed=Не вдалося додати файли схематик -schematics.back_to=Назад до "%s" schematics.create_directory.prompt=Введіть нову назву каталогу schematics.create_directory.failed=Не вдалося створити каталог schematics.create_directory.failed.already_exists=Каталог вже існує diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index a7846dacf1..6df2e37398 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1052,7 +1052,6 @@ schematics=原理圖 schematics.add=新增原理圖 schematics.add.failed=新增原理圖失敗 schematics.add.title=選取要新增的原理圖檔案 -schematics.back_to=返回到「%s」 schematics.create_directory=建立目錄 schematics.create_directory.prompt=請輸入新目錄名稱 schematics.create_directory.failed=建立目錄失敗 @@ -1068,8 +1067,16 @@ schematics.info.time_created=建立時間 schematics.info.time_modified=修改時間 schematics.info.total_blocks=總方塊數 schematics.info.total_volume=總體積 +schematics.info.type=原理圖類型 +schematics.info.type.litematic=Litematic (Litematica) +schematics.info.type.nbt_structure=NBT 結構 (原版) +schematics.info.type.schem=Schem (Schematica, WE, MCEdit) schematics.info.version=原理圖版本 +schematics.info.mc_data_version=Minecraft 資料版本 schematics.manage=原理圖管理 +schematics.warning.litematica_disabled=Litematica 或 Forgematica 已被停用 +schematics.warning.no_litematica=未安裝 Litematica 或 Forgematica +schematics.warning.no_litematica_install=未安裝 Litematica 或 Forgematica,點選以安裝 schematics.sub_items=%d 個子項 search=搜尋 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 b4e00451ab..8ab2e68a46 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1057,7 +1057,6 @@ schematics=原理图 schematics.add=添加原理图 schematics.add.failed=添加原理图失败 schematics.add.title=选择要添加的原理图文件 -schematics.back_to=返回到“%s” schematics.create_directory=创建文件夹 schematics.create_directory.prompt=请输入新文件夹名称 schematics.create_directory.failed=创建文件夹失败 @@ -1073,8 +1072,16 @@ schematics.info.time_created=创建时间 schematics.info.time_modified=修改时间 schematics.info.total_blocks=总方块数 schematics.info.total_volume=总体积 +schematics.info.type=原理图类型 +schematics.info.type.litematic=Litematic (Litematica) +schematics.info.type.nbt_structure=NBT 结构 (原版) +schematics.info.type.schem=Schem (Schematica, WE, MCEdit) schematics.info.version=原理图版本 +schematics.info.mc_data_version=Minecraft 数据版本 schematics.manage=原理图管理 +schematics.warning.litematica_disabled=Litematica 或 Forgematica 已被禁用 +schematics.warning.no_litematica=未安装 Litematica 或 Forgematica +schematics.warning.no_litematica_install=未安装 Litematica 或 Forgematica,点击以安装 schematics.sub_items=%d 个子项 search=搜索 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 42d5fcafec..f0e26f8f92 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -24,8 +24,10 @@ import org.jackhuang.hmcl.game.tlauncher.TLauncherVersion; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.mod.ModpackConfiguration; +import org.jackhuang.hmcl.schematic.LitematicaConfig; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; @@ -554,7 +556,25 @@ public Path getBackupsDirectory(String id) { } public Path getSchematicsDirectory(String id) { - return getRunDirectory(id).resolve("schematics"); + var runDir = getRunDirectory(id); + var config = runDir.resolve("config").resolve("litematica.json"); + Path dir = runDir.resolve("schematics"); + if (Files.isRegularFile(config)) { + try { + var conf = JsonUtils.fromJsonFile(config, LitematicaConfig.class); + if (conf != null + && conf.generic() != null + && conf.generic().customSchematicBaseDirectoryEnabled() + && StringUtils.isNotBlank(conf.generic().customSchematicBaseDirectory()) + ) { + var p = Path.of(conf.generic().customSchematicBaseDirectory()); + dir = p.isAbsolute() ? p : runDir.resolve(p); + } + } catch (Exception e) { + LOG.warning("Failed to load custom schematics directory from litematica config at '%s'".formatted(config), e); + } + } + return dir; } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java index 837bdbcb02..57530cf532 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java @@ -62,7 +62,6 @@ enum ModSuggestion { "irisUpdateInfo.json", // Iris "modernfix", // ModernFix "modtranslations", // Mod translations - "schematics", // Schematics mod "journeymap/data", // JourneyMap "mods/.connector" // Sinytra Connector ); @@ -73,6 +72,7 @@ enum ModSuggestion { "blueprints" /* BuildCraft */, "optionsof.txt" /* OptiFine */, "journeymap" /* JourneyMap */, + "schematics" /* Schematics mod */, "optionsshaders.txt", "mods" + File.separator + "VoxelMods"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java index 6297e49e94..1a58da653d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java @@ -63,8 +63,10 @@ private interface ModMetadataReader { private final GameRepository repository; private final String id; + private String gameVersion; private final TreeSet localModFiles = new TreeSet<>(); private final HashMap, LocalMod> localMods = new HashMap<>(); + private final EnumSet supportedLoaders = EnumSet.noneOf(ModLoaderType.class); private LibraryAnalyzer analyzer; private boolean loaded = false; @@ -99,6 +101,14 @@ public boolean hasMod(String modId, ModLoaderType modLoaderType) { return localMods.containsKey(pair(modId, modLoaderType)); } + public String getGameVersion() { + return gameVersion; + } + + public EnumSet getSupportedLoaders() { + return EnumSet.copyOf(supportedLoaders); + } + private void addModInfo(Path file) { String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); String extension = fileName.substring(fileName.lastIndexOf(".") + 1); @@ -174,7 +184,11 @@ public void refreshMods() throws IOException { localModFiles.clear(); localMods.clear(); - analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null); + var resolved = getRepository().getResolvedPreservingPatchesVersion(id); + gameVersion = repository.getGameVersion(resolved).orElse(null); + analyzer = LibraryAnalyzer.analyze(resolved, gameVersion); + + updateSupportedLoaders(); boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) || analyzer.has(LibraryAnalyzer.LibraryType.QUILT); @@ -225,6 +239,45 @@ public void removeMods(LocalModFile... localModFiles) throws IOException { } } + private void updateSupportedLoaders() { + supportedLoaders.clear(); + + for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { + if (type.isModLoader() && this.analyzer.has(type)) { + ModLoaderType modLoaderType = type.getModLoaderType(); + if (modLoaderType != null) { + supportedLoaders.add(modLoaderType); + + if (modLoaderType == ModLoaderType.CLEANROOM) + supportedLoaders.add(ModLoaderType.FORGE); + } + } + } + + if (this.analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { + supportedLoaders.add(ModLoaderType.FORGE); + } + + if (this.analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + + if (this.analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + + if (this.analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && hasMod("kilt", ModLoaderType.FABRIC)) { + supportedLoaders.add(ModLoaderType.FORGE); + supportedLoaders.add(ModLoaderType.NEO_FORGED); + } + + // Sinytra Connector + if (this.analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && hasMod("connectormod", ModLoaderType.NEO_FORGED) + || "1.20.1".equals(gameVersion) && this.analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && hasMod("connectormod", ModLoaderType.FORGE)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + } + public void rollback(LocalModFile from, LocalModFile to) throws IOException { if (!loaded) { throw new IllegalStateException("ModManager Not loaded"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java index 3ad27a3692..948dbf2e47 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java @@ -17,46 +17,26 @@ */ package org.jackhuang.hmcl.schematic; -import com.github.steveice10.opennbt.NBTIO; import com.github.steveice10.opennbt.tag.builtin.*; -import javafx.geometry.Point3D; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.Point3I; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; -import java.util.zip.GZIPInputStream; +import java.util.OptionalInt; /** * @author Glavo * @see The Litematic file format */ -public final class LitematicFile { - - private static int tryGetInt(Tag tag) { - return tag instanceof IntTag ? ((IntTag) tag).getValue() : 0; - } - - private static @Nullable Instant tryGetLongTimestamp(Tag tag) { - if (tag instanceof LongTag) { - return Instant.ofEpochMilli(((LongTag) tag).getValue()); - } - return null; - } - - private static @Nullable String tryGetString(Tag tag) { - return tag instanceof StringTag ? ((StringTag) tag).getValue() : null; - } +public final class LitematicFile extends Schematic { public static LitematicFile load(Path file) throws IOException { - CompoundTag root; - try (InputStream in = new GZIPInputStream(Files.newInputStream(file))) { - root = (CompoundTag) NBTIO.readTag(in); - } + CompoundTag root = readRoot(file); Tag versionTag = root.get("Version"); if (versionTag == null) @@ -75,19 +55,29 @@ else if (!(metadataTag instanceof CompoundTag)) if (regionsTag instanceof CompoundTag) regions = ((CompoundTag) regionsTag).size(); + Point3I enclosingSize = null; + Tag enclosingSizeTag = ((CompoundTag) metadataTag).get("EnclosingSize"); + if (enclosingSizeTag instanceof CompoundTag) { + CompoundTag list = (CompoundTag) enclosingSizeTag; + int x = tryGetInt(list.get("x")).orElse(0); + int y = tryGetInt(list.get("y")).orElse(0); + int z = tryGetInt(list.get("z")).orElse(0); + + if (x > 0 && y > 0 && z > 0) enclosingSize = new Point3I(x, y, z); + } + + Tag subVersionTag = root.get("SubVersion"); return new LitematicFile(file, (CompoundTag) metadataTag, ((IntTag) versionTag).getValue(), - tryGetInt(root.get("SubVersion")), - tryGetInt(root.get("MinecraftDataVersion")), - regions + tryGetInt(subVersionTag).orElse(-1), + tryGetInt(root.get("MinecraftDataVersion")).orElse(0), + regions, + enclosingSize ); } - private final @NotNull Path file; - private final int version; private final int subVersion; - private final int minecraftDataVersion; private final int regionCount; private final int[] previewImageData; private final String name; @@ -97,14 +87,12 @@ else if (!(metadataTag instanceof CompoundTag)) private final Instant timeModified; private final int totalBlocks; private final int totalVolume; - private final Point3D enclosingSize; private LitematicFile(@NotNull Path file, @NotNull CompoundTag metadata, - int version, int subVersion, int minecraftDataVersion, int regionCount) { - this.file = file; + int version, int subVersion, int minecraftDataVersion, int regionCount, Point3I enclosingSize) { + super(file, minecraftDataVersion, enclosingSize); this.version = version; this.subVersion = subVersion; - this.minecraftDataVersion = minecraftDataVersion; this.regionCount = regionCount; Tag previewImageData = metadata.get("PreviewImageData"); @@ -117,49 +105,37 @@ private LitematicFile(@NotNull Path file, @NotNull CompoundTag metadata, this.description = tryGetString(metadata.get("Description")); this.timeCreated = tryGetLongTimestamp(metadata.get("TimeCreated")); this.timeModified = tryGetLongTimestamp(metadata.get("TimeModified")); - this.totalBlocks = tryGetInt(metadata.get("TotalBlocks")); - this.totalVolume = tryGetInt(metadata.get("TotalVolume")); - - - Point3D enclosingSize = null; - Tag enclosingSizeTag = metadata.get("EnclosingSize"); - if (enclosingSizeTag instanceof CompoundTag) { - CompoundTag list = (CompoundTag) enclosingSizeTag; - int x = tryGetInt(list.get("x")); - int y = tryGetInt(list.get("y")); - int z = tryGetInt(list.get("z")); - - if (x >= 0 && y >= 0 && z >= 0) - enclosingSize = new Point3D(x, y, z); - } - this.enclosingSize = enclosingSize; + this.totalBlocks = tryGetInt(metadata.get("TotalBlocks")).orElse(-1); + this.totalVolume = tryGetInt(metadata.get("TotalVolume")).orElse(-1); } - public @NotNull Path getFile() { - return file; + @Override + public SchematicType getType() { + return SchematicType.LITEMATIC; } - public int getVersion() { - return version; + @Override + public OptionalInt getVersion() { + return OptionalInt.of(version); } - public int getSubVersion() { - return subVersion; - } - - public int getMinecraftDataVersion() { - return minecraftDataVersion; + @Override + public OptionalInt getSubVersion() { + return Lang.wrapWithMinValue(subVersion, 0); } + @Override public int[] getPreviewImageData() { return previewImageData != null ? previewImageData.clone() : null; } - public String getName() { - return name; + @Override + public @NotNull String getName() { + return StringUtils.isNotBlank(name) ? name : super.getName(); } + @Override public String getAuthor() { return author; } @@ -168,27 +144,29 @@ public String getDescription() { return description; } + @Override public Instant getTimeCreated() { return timeCreated; } + @Override public Instant getTimeModified() { return timeModified; } - public int getTotalBlocks() { - return totalBlocks; + @Override + public OptionalInt getTotalBlocks() { + return Lang.wrapWithMinValue(totalBlocks, 1); } - public int getTotalVolume() { - return totalVolume; + @Override + public OptionalInt getTotalVolume() { + return Lang.wrapWithMinValue(totalVolume, 1); } - public Point3D getEnclosingSize() { - return enclosingSize; + @Override + public OptionalInt getRegionCount() { + return Lang.wrapWithMinValue(regionCount, 1); } - public int getRegionCount() { - return regionCount; - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicaConfig.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicaConfig.java new file mode 100644 index 0000000000..bb4b5a11b4 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicaConfig.java @@ -0,0 +1,25 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.schematic; + +import com.google.gson.annotations.SerializedName; + +public record LitematicaConfig(@SerializedName("Generic") Generic generic) { + public record Generic(boolean customSchematicBaseDirectoryEnabled, String customSchematicBaseDirectory) { + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/NBTStructureFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/NBTStructureFile.java new file mode 100644 index 0000000000..ae6c3d7a24 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/NBTStructureFile.java @@ -0,0 +1,82 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.schematic; + +import com.github.steveice10.opennbt.tag.builtin.*; +import org.jackhuang.hmcl.util.Point3I; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/// @author Calboot +/// @see Structure File +public final class NBTStructureFile extends Schematic { + + public static NBTStructureFile load(Path file) throws IOException { + + CompoundTag root = Schematic.readRoot(file); + + Tag dataVersionTag = root.get("DataVersion"); + if (dataVersionTag == null) + throw new IOException("DataVersion tag not found"); + else if (!(dataVersionTag instanceof IntTag)) + throw new IOException("DataVersion tag is not an integer"); + + Tag sizeTag = root.get("size"); + if (sizeTag == null) + throw new IOException("size tag not found"); + else if (!(sizeTag instanceof ListTag)) + throw new IOException("size tag is not a list"); + List size = ((ListTag) sizeTag).getValue(); + if (size.size() != 3) + throw new IOException("size tag does not have 3 elements"); + Tag xTag = size.get(0); + Tag yTag = size.get(1); + Tag zTag = size.get(2); + Point3I enclosingSize = null; + if (xTag != null && yTag != null && zTag != null) { + int width = tryGetInt(xTag).orElse(0); + int height = tryGetInt(yTag).orElse(0); + int length = tryGetInt(zTag).orElse(0); + if (width > 0 && height > 0 && length > 0) { + enclosingSize = new Point3I(width, height, length); + } + } + + return new NBTStructureFile(file, ((IntTag) dataVersionTag).getValue(), tryGetString(root.get("author")), enclosingSize); + } + + private final String author; + + private NBTStructureFile(Path file, int dataVersion, String author, Point3I enclosingSize) { + super(file, dataVersion, enclosingSize); + this.author = author; + } + + @Override + public SchematicType getType() { + return SchematicType.NBT_STRUCTURE; + } + + @Override + public String getAuthor() { + return author; + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchemFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchemFile.java new file mode 100644 index 0000000000..c3cf8acc6e --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchemFile.java @@ -0,0 +1,115 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.schematic; + +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; +import com.github.steveice10.opennbt.tag.builtin.IntTag; +import com.github.steveice10.opennbt.tag.builtin.StringTag; +import com.github.steveice10.opennbt.tag.builtin.Tag; +import org.jackhuang.hmcl.util.Point3I; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.OptionalInt; + +/// @author Calboot +/// @see Schematic File Format Wiki +/// @see Schematica +/// @see Schem +public final class SchemFile extends Schematic { + + private static final int DATA_VERSION_MC_1_13_2 = 1631; + + public static SchemFile load(Path file) throws IOException { + + CompoundTag root = readRoot(file); + + if (root.contains("Materials")) return loadLegacy(file, root); + else if (root.contains("Version")) return loadSponge(file, root); + throw new IOException("No Materials tag or Version tag found"); + } + + private static SchemFile loadLegacy(Path file, CompoundTag root) throws IOException { + Tag materialsTag = root.get("Materials"); + if (!(materialsTag instanceof StringTag)) + throw new IOException("Materials tag is not a string"); + + Tag widthTag = root.get("Width"); + Tag heightTag = root.get("Height"); + Tag lengthTag = root.get("Length"); + Point3I enclosingSize = null; + if (widthTag != null && heightTag != null && lengthTag != null) { + short width = tryGetShort(widthTag); + short height = tryGetShort(heightTag); + short length = tryGetShort(lengthTag); + if (width >= 0 && height >= 0 && length >= 0) { + enclosingSize = new Point3I(width, height, length); + } + } + + return new SchemFile(file, ((StringTag) materialsTag).getValue(), 0, 0, enclosingSize); + } + + private static SchemFile loadSponge(Path file, CompoundTag root) throws IOException { + Tag versionTag = root.get("Version"); + if (!(versionTag instanceof IntTag)) + throw new IOException("Version tag is not an integer"); + + Tag dataVersionTag = root.get("DataVersion"); + int dataVersion = dataVersionTag == null ? DATA_VERSION_MC_1_13_2 : tryGetInt(dataVersionTag).orElse(0); + + Tag widthTag = root.get("Width"); + Tag heightTag = root.get("Height"); + Tag lengthTag = root.get("Length"); + Point3I enclosingSize = null; + if (widthTag != null && heightTag != null && lengthTag != null) { + int width = tryGetShort(widthTag) & 0xFFFF; + int height = tryGetShort(heightTag) & 0xFFFF; + int length = tryGetShort(lengthTag) & 0xFFFF; + enclosingSize = new Point3I(width, height, length); + } + + return new SchemFile(file, null, dataVersion, ((IntTag) versionTag).getValue(), enclosingSize); + } + + private final String materials; + private final int version; + + private SchemFile(Path file, @Nullable String materials, int dataVersion, int version, Point3I enclosingSize) { + super(file, dataVersion, enclosingSize); + this.materials = materials; + this.version = version; + } + + @Override + public SchematicType getType() { + return SchematicType.SCHEM; + } + + @Override + public OptionalInt getVersion() { + return version > 0 ? OptionalInt.of(version) : OptionalInt.empty(); + } + + @Override + public String getMinecraftVersion() { + return materials == null ? super.getMinecraftVersion() : materials; + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/Schematic.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/Schematic.java new file mode 100644 index 0000000000..03f99edd87 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/Schematic.java @@ -0,0 +1,159 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.schematic; + +import com.github.steveice10.opennbt.NBTIO; +import com.github.steveice10.opennbt.tag.builtin.*; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.Point3I; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.OptionalInt; +import java.util.zip.GZIPInputStream; + +public sealed abstract class Schematic permits LitematicFile, SchemFile, NBTStructureFile { + + public static boolean isFileSchematic(Path file) { + return SchematicType.getType(file) != null; + } + + @Nullable + public static Schematic load(Path file) throws IOException { + var type = SchematicType.getType(file); + if (type == null) return null; + return switch (type) { + case LITEMATIC -> LitematicFile.load(file); + case SCHEM -> SchemFile.load(file); + case NBT_STRUCTURE -> NBTStructureFile.load(file); + }; + } + + public static CompoundTag readRoot(Path file) throws IOException { + CompoundTag root; + try (InputStream in = new GZIPInputStream(Files.newInputStream(file))) { + root = (CompoundTag) NBTIO.readTag(in); + } + return root; + } + + public static OptionalInt tryGetInt(Tag tag) { + return tag instanceof IntTag ? OptionalInt.of(((IntTag) tag).getValue()) : OptionalInt.empty(); + } + + public static short tryGetShort(Tag tag) { + return tag instanceof ShortTag ? ((ShortTag) tag).getValue() : 0; + } + + public static @Nullable Instant tryGetLongTimestamp(Tag tag) { + if (tag instanceof LongTag) { + return Instant.ofEpochMilli(((LongTag) tag).getValue()); + } + return null; + } + + public static @Nullable String tryGetString(Tag tag) { + return tag instanceof StringTag ? ((StringTag) tag).getValue() : null; + } + + private final Path file; + private final int dataVersion; + private final Point3I enclosingSize; + + protected Schematic(Path file, int dataVersion, @Nullable Point3I enclosingSize) { + this.file = file; + this.dataVersion = dataVersion; + this.enclosingSize = enclosingSize; + } + + public abstract SchematicType getType(); + + @NotNull + public Path getFile() { + return file; + } + + public OptionalInt getVersion() { + return OptionalInt.empty(); + } + + /// Non-negative, otherwise empty + public OptionalInt getSubVersion() { + return OptionalInt.empty(); + } + + /// At least 100, otherwise empty + public OptionalInt getMinecraftDataVersion() { + return Lang.wrapWithMinValue(dataVersion, /* 15w32a */ 100); + } + + @Nullable + public String getMinecraftVersion() { + return getMinecraftDataVersion().isPresent() ? Integer.toString(getMinecraftDataVersion().getAsInt()) : null; + } + + @NotNull + public String getName() { + return FileUtils.getNameWithoutExtension(getFile()); + } + + @Nullable + public String getAuthor() { + return null; + } + + @Nullable + public Instant getTimeCreated() { + return null; + } + + @Nullable + public Instant getTimeModified() { + return null; + } + + public OptionalInt getRegionCount() { + return OptionalInt.empty(); + } + + public OptionalInt getTotalBlocks() { + return OptionalInt.empty(); + } + + @Nullable + public Point3I getEnclosingSize() { + return enclosingSize; + } + + public OptionalInt getTotalVolume() { + var enclosingSize = getEnclosingSize(); + if (enclosingSize != null) return OptionalInt.of(enclosingSize.x() * enclosingSize.y() * enclosingSize.z()); + return OptionalInt.empty(); + } + + public int @Nullable [] getPreviewImageData() { + return null; + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchematicType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchematicType.java new file mode 100644 index 0000000000..50bcc7ef58 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchematicType.java @@ -0,0 +1,45 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.schematic; + +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public enum SchematicType { + LITEMATIC("litematic"), + NBT_STRUCTURE("nbt"), + SCHEM("schematic", "schem"); + + public static SchematicType getType(Path file) { + if (file == null || !Files.isRegularFile(file)) return null; + String ext = FileUtils.getExtension(file); + for (SchematicType type : values()) { + if (type.extensions.contains(ext)) return type; + } + return null; + } + + public final List extensions; + + SchematicType(String... ext) { + this.extensions = List.of(ext); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index f55008ca11..2f5030c8e0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -138,6 +138,10 @@ public static Optional tryCast(Object obj, Class clazz) { } } + public static OptionalInt wrapWithMinValue(int value, int min) { + return value >= min ? OptionalInt.of(value) : OptionalInt.empty(); + } + public static T getOrDefault(List a, int index, T defaultValue) { return index < 0 || index >= a.size() ? defaultValue : a.get(index); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Point3I.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Point3I.java new file mode 100644 index 0000000000..d478dca470 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Point3I.java @@ -0,0 +1,24 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.util; + +public record Point3I(int x, int y, int z) { + + public static final Point3I ZERO = new Point3I(0, 0, 0); + +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/LitematicFileTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/LitematicFileTest.java deleted file mode 100644 index 1a42bfba96..0000000000 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/LitematicFileTest.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2025 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.schematic; - -import javafx.geometry.Point3D; -import org.jackhuang.hmcl.game.CrashReportAnalyzerTest; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Paths; -import java.time.Instant; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -public final class LitematicFileTest { - private static LitematicFile load(String name) throws IOException, URISyntaxException { - URL resource = CrashReportAnalyzerTest.class.getResource(name); - if (resource == null) - throw new IOException("Resource not found: " + name); - return LitematicFile.load(Paths.get(resource.toURI())); - } - - @Test - public void test() throws Exception { - LitematicFile file = load("/schematics/test.litematic"); - assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", file.getName()); - assertEquals("hsds", file.getAuthor()); - assertEquals("", file.getDescription()); - assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeCreated()); - assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeModified()); - assertEquals(1334, file.getTotalBlocks()); - assertEquals(5746, file.getTotalVolume()); - assertEquals(new Point3D(17, 26, 13), file.getEnclosingSize()); - assertEquals(1, file.getRegionCount()); - } -} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/SchematicTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/SchematicTest.java new file mode 100644 index 0000000000..529c83b50e --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/SchematicTest.java @@ -0,0 +1,88 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 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.schematic; + +import org.jackhuang.hmcl.game.CrashReportAnalyzerTest; +import org.jackhuang.hmcl.util.Point3I; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public final class SchematicTest { + private static Schematic load(String name) throws IOException, URISyntaxException { + URL resource = CrashReportAnalyzerTest.class.getResource(name); + if (resource == null) + throw new IOException("Resource not found: " + name); + return Schematic.load(Paths.get(resource.toURI())); + } + + @Test + public void test() throws Exception { + { + LitematicFile lFile = (LitematicFile) load("/schematics/test.litematic"); + assertEquals(SchematicType.LITEMATIC, lFile.getType()); + assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", lFile.getName()); + assertEquals("hsds", lFile.getAuthor()); + assertEquals("", lFile.getDescription()); + assertEquals(Instant.ofEpochMilli(1746443586433L), lFile.getTimeCreated()); + assertEquals(Instant.ofEpochMilli(1746443586433L), lFile.getTimeModified()); + assertEquals(1334, lFile.getTotalBlocks().orElse(0)); + assertEquals(5746, lFile.getTotalVolume().orElse(0)); + assertEquals(new Point3I(17, 26, 13), lFile.getEnclosingSize()); + assertEquals(1, lFile.getRegionCount().orElse(0)); + assertEquals(4325, lFile.getMinecraftDataVersion().orElse(0)); + assertEquals("4325", lFile.getMinecraftVersion()); + assertEquals(7, lFile.getVersion().orElse(0)); + assertEquals(1, lFile.getSubVersion().orElse(-1)); + } + + { + SchemFile sFile = (SchemFile) load("/schematics/test.schematic"); + assertEquals(SchematicType.SCHEM, sFile.getType()); + assertEquals("test", sFile.getName()); + assertEquals(new Point3I(28, 35, 18), sFile.getEnclosingSize()); + assertEquals(17640, sFile.getTotalVolume().orElse(0)); + assertEquals("Alpha", sFile.getMinecraftVersion()); + } + + { + SchemFile sFileSponge = (SchemFile) load("/schematics/test.schem"); + assertEquals(SchematicType.SCHEM, sFileSponge.getType()); + assertEquals("test", sFileSponge.getName()); + assertEquals(3465, sFileSponge.getMinecraftDataVersion().orElse(0)); + assertEquals("3465", sFileSponge.getMinecraftVersion()); + assertEquals(new Point3I(9, 5, 9), sFileSponge.getEnclosingSize()); + } + + { + NBTStructureFile nFile = (NBTStructureFile) load("/schematics/test.nbt"); + assertEquals(SchematicType.NBT_STRUCTURE, nFile.getType()); + assertEquals("test", nFile.getName()); + assertEquals(new Point3I(9, 11, 13), nFile.getEnclosingSize()); + assertEquals(1287, nFile.getTotalVolume().orElse(0)); + assertEquals(3465, nFile.getMinecraftDataVersion().orElse(0)); + assertEquals("3465", nFile.getMinecraftVersion()); + } + } +} diff --git a/HMCLCore/src/test/resources/schematics/test.nbt b/HMCLCore/src/test/resources/schematics/test.nbt new file mode 100644 index 0000000000..389866b030 Binary files /dev/null and b/HMCLCore/src/test/resources/schematics/test.nbt differ diff --git a/HMCLCore/src/test/resources/schematics/test.schem b/HMCLCore/src/test/resources/schematics/test.schem new file mode 100644 index 0000000000..ae4c821962 Binary files /dev/null and b/HMCLCore/src/test/resources/schematics/test.schem differ diff --git a/HMCLCore/src/test/resources/schematics/test.schematic b/HMCLCore/src/test/resources/schematics/test.schematic new file mode 100644 index 0000000000..eda71a36b9 Binary files /dev/null and b/HMCLCore/src/test/resources/schematics/test.schematic differ