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 ab53802818..c3acf6343a 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 @@ -98,5 +98,9 @@ public abstract class MDListCell extends ListCell { }); } + 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/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index 11b12ff9f2..a8f41eec27 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -158,6 +158,10 @@ public class DownloadListPage extends Control implements DecoratorPage { FXUtils.runInFX(() -> selectedInstance.set(instanceId)); } + public DownloadPage.DownloadCallback getCallback() { + return callback; + } + private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) { retrySearch = null; setLoading(true); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index aee7e753df..d53d9355f6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -207,6 +207,14 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub)); } + private void onBrowseSchematics() { + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance == null) { + return; + } + FXUtils.openFolder(gameInstance.getSchematicsDirectory()); + } + private void redownloadAssetIndex() { HMCLGameInstance gameInstance = requireGameInstance(); if (gameInstance != null) { @@ -358,7 +366,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator 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/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java index 502deb93e9..afa638b972 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java @@ -35,6 +35,10 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage { return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS); } + public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) { + return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, null, ModrinthRemoteAddonRepository.MODS); + } + public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) { return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 8de0d08066..5c7caedb18 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -95,7 +95,6 @@ public final class ModListPage extends ListPageBase i private ModManager modManager; private @Nullable HMCLGameInstance gameInstance; - private String gameVersion; final EnumSet supportedLoaders = EnumSet.noneOf(ModLoaderType.class); @@ -137,8 +136,6 @@ public final class ModListPage extends ListPageBase i return; } - this.gameVersion = gameInstance.getVersion().toString(); - loadMods(gameInstance.getModManager()); } @@ -164,7 +161,8 @@ public final class ModListPage extends ListPageBase i return; } - updateSupportedLoaders(modManager); + supportedLoaders.clear(); + supportedLoaders.addAll(modManager.getSupportedLoaders()); if (exception == null) { getItems().setAll(list); @@ -176,51 +174,6 @@ public final class ModListPage extends ListPageBase i }, Schedulers.javafx()); } - private void updateSupportedLoaders(ModManager modManager) { - supportedLoaders.clear(); - - GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer(); - if (analyzer == null) { - Collections.addAll(supportedLoaders, ModLoaderType.values()); - return; - } - - for (GameComponentType type : GameComponentType.MOD_LOADERS) { - 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(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { - supportedLoaders.add(ModLoaderType.FORGE); - } - - if (analyzer.has(GameComponentType.QUILT)) { - supportedLoaders.add(ModLoaderType.FABRIC); - } - - if (analyzer.has(GameComponentType.LEGACY_FABRIC)) { - supportedLoaders.add(ModLoaderType.FABRIC); - } - - if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { - supportedLoaders.add(ModLoaderType.FORGE); - supportedLoaders.add(ModLoaderType.NEO_FORGE); - } - - // Sinytra Connector - if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)) - || "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.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/instances/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java index 556c9fb1da..a8ec507dc7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java @@ -20,32 +20,48 @@ package org.jackhuang.hmcl.ui.instances; 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.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.control.ListCell; -import javafx.scene.control.Skin; -import javafx.scene.control.Tooltip; +import javafx.scene.control.*; 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.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.mod.LocalMod; +import org.jackhuang.hmcl.addon.mod.LocalModFile; +import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.schematic.LitematicFile; +import org.jackhuang.hmcl.schematic.Schematic; +import org.jackhuang.hmcl.schematic.SchematicType; +import org.jackhuang.hmcl.setting.DownloadProviders; 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.FileNameSet; -import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.ui.nbt.NBTEditorPage; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.SynchronizedExceptionalLazy; 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; @@ -55,10 +71,12 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.*; -import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; 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.createToolbarButton2; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -67,6 +85,23 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; */ public final class SchematicsPage extends ListPageBase { + private static final SynchronizedExceptionalLazy litematicaLazy = new SynchronizedExceptionalLazy<>(() -> { + try { + return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "litematica"); + } catch (IOException e) { + LOG.warning("Failed to fetch litematica", e); + throw e; + } + }); + private static final SynchronizedExceptionalLazy forgematicaLazy = new SynchronizedExceptionalLazy<>(() -> { + try { + return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "forgematica"); + } catch (IOException e) { + LOG.warning("Failed to fetch forgematica", e); + throw e; + } + }); + private static String translateAuthorName(String author) { if (I18n.isUseChinese() && "hsds".equals(author)) { return "黑山大叔"; @@ -74,9 +109,22 @@ public final class SchematicsPage extends ListPageBase { return author; } + private static String translateType(SchematicType type) { + return switch (type) { + case SCHEM -> i18n("schematics.info.type.schem"); + case NBT_STRUCTURE -> i18n("schematics.info.type.nbt_structure"); + case LITEMATIC -> i18n("schematics.info.type.litematic"); + }; + } + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + + private @Nullable HMCLGameInstance gameInstance; private Path schematicsDirectory; - private DirItem currentDirectory; + + private final ObjectProperty currentDirectory = new SimpleObjectProperty<>(this, "currentDirectory", null); + private final BooleanProperty isRootProperty = new SimpleBooleanProperty(this, "isRoot", true); + private final ObjectProperty downloadTarget = new SimpleObjectProperty<>(this, "downloadTarget"); /// Creates a schematics list that reloads when `instanceContext` changes. /// @@ -84,7 +132,7 @@ public final class SchematicsPage extends ListPageBase { public SchematicsPage(ObservableValue instanceContext) { Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, - file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"), + file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file), this::addFiles ); @@ -97,53 +145,120 @@ public final class SchematicsPage extends ListPageBase { @Override protected Skin createDefaultSkin() { - return new SchematicsPageSkin(); + return new SchematicsPageSkin(this); } public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameInstance gameInstance = instance.instance(); - this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null; + this.gameInstance = instance.instance(); + + if (gameInstance == null) { + this.schematicsDirectory = null; + return; + } + + this.schematicsDirectory = gameInstance.getSchematicsDirectory(); 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 downloadLitematica() { + if (downloadTarget.get() != null && gameInstance != null) { + var modDownloads = HMCLLocalizedDownloadListPage.ofModrinthMod(org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD, false); + modDownloads.loadInstance(HMCLGameInstance.Optional.of(gameInstance)); + Controllers.navigate(new DownloadPage( + modDownloads, + downloadTarget.get(), + modDownloads.getInstanceOptional(), + modDownloads.getCallback()) + ); + } + } + public void refresh() { - Path schematicsDirectory = this.schematicsDirectory; if (schematicsDirectory == null) return; + HMCLGameInstance currentGameInstance = this.gameInstance; + if (currentGameInstance == 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; - } - } - - navigateTo(target); - } else { - LOG.warning("Failed to load schematics", exception); + DirItem currentDir = currentDirectoryProperty().get(); + Task.supplyAsync(Schedulers.io(), () -> { + DirItem target = loadRoot(schematicsDirectory); + if (currentDir != null) { + loop: + for (String dirName : currentDir.relativePath) { + try { + target.preLoad(); + } catch (IOException e) { + LOG.warning("Failed to preload sub-directories in " + target, e); + break; } - }).start(); + for (var dirChild : target.dirChildren) { + if (dirChild.getName().equals(dirName)) { + target = dirChild; + continue loop; + } + } + break; + } + } + return target; + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (!Objects.equals(currentGameInstance, this.gameInstance)) return; + if (exception == null) { + navigateTo(result); + } else { + LOG.warning("Failed to load schematics", exception); + } + setLoading(false); + }).start(); + + downloadTarget.set(null); + Task.supplyAsync(Schedulers.io(), () -> { + var modManager = currentGameInstance.getModManager(); + modManager.analyze(); + var analyzer = modManager.getComponentAnalyzer(); + if (analyzer == null) return null; + var modIds = modManager.getLocalFiles().stream() + .map(LocalModFile::getMod) + .map(LocalMod::getId) + .collect(Collectors.toSet()); + if (modIds.contains("litematica") || modIds.contains("forgematica")) return null; + var gameVersionNumber = GameVersionNumber.asGameVersion(modManager.getGameVersion()); + if (!gameVersionNumber.isAtLeast("1.12", "17w31a")) + return null; + if ((analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.NEO_FORGE)) + && gameVersionNumber.isAtLeast("1.16.4", "20w45a")) + return forgematicaLazy.get(); + return litematicaLazy.get(); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (!Objects.equals(currentGameInstance, gameInstance)) return; + if (exception == null) { + downloadTarget.set(result); + } else { + LOG.warning("Failed to fetch litematica", exception); + } + }).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); @@ -162,7 +277,7 @@ public final class SchematicsPage extends ListPageBase { FileChooser fileChooser = new FileChooser(); fileChooser.setTitle(i18n("schematics.add.title")); fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter( - i18n("extension.schematic"), "*.litematic")); + i18n("extension.schematic"), "*.litematic", "*.nbt", "*.schematic", "*.schem")); List files = Controllers.showOpenMultipleDialog(fileChooser); if (files != null && !files.isEmpty()) { addFiles(files); @@ -170,10 +285,9 @@ public final class SchematicsPage extends ListPageBase { } public void onCreateDirectory() { - if (currentDirectory == null) - return; + if (currentDirectoryProperty().get() == null) return; - Path parent = currentDirectory.path; + Path parent = currentDirectoryProperty().get().getPath(); FileNameSet existingPaths; try { @@ -208,37 +322,29 @@ public final class SchematicsPage extends ListPageBase { new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains)); } - private DirItem loadAll(Path dir, @Nullable DirItem parent) { - DirItem item = new DirItem(dir, parent); + public void onRevealSchematicsFolder() { + var d = currentDirectoryProperty().get(); + var p = d != null ? d.getPath() : schematicsDirectory; + if (p != null) FXUtils.openFolder(p); + } - 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); - } - - 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 { @@ -280,12 +386,28 @@ public final class SchematicsPage extends ListPageBase { } } - private final class BackItem extends Item { + private final class DirItem extends Item { + final ReentrantLock lock = new ReentrantLock(); - private final DirItem parent; + 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; - BackItem(DirItem parent) { + DirItem(Path path, @Nullable DirItem parent) { + this.path = path; this.parent = parent; + + if (parent != null) { + this.relativePath = new ArrayList<>(parent.relativePath); + relativePath.add(FileUtils.getName(path)); + } else { + this.relativePath = Collections.emptyList(); + } } @Override @@ -293,65 +415,6 @@ public final class SchematicsPage extends ListPageBase { 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 relativePath; - - DirItem(Path path, @Nullable DirItem parent) { - this.path = path; - this.parent = parent; - - if (parent != null) { - this.relativePath = new ArrayList<>(parent.relativePath); - relativePath.add(path.getFileName().toString()); - } else { - this.relativePath = Collections.emptyList(); - } - } - - @Override - int order() { - return 1; - } - @Override Path getPath() { return path; @@ -359,12 +422,12 @@ public final class SchematicsPage extends ListPageBase { @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 @@ -372,6 +435,66 @@ public final class SchematicsPage extends ListPageBase { return SVG.FOLDER; } + void preLoad() throws IOException { + lock.lock(); + try { + if (this.preLoaded) return; + this.size = 0; + this.dirChildren.clear(); + 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; + } finally { + lock.unlock(); + } + } + + void load() { + lock.lock(); + try { + if (this.loaded) return; + + this.children.clear(); + + preLoad(); + for (var dir : dirChildren) { + try { + dir.preLoad(); + } catch (IOException e) { + LOG.warning("Failed to pre-load schematics in " + dir.path, e); + } + this.children.add(dir); + } + try (Stream stream = Files.list(path)) { + 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); + } + }); + } + + this.children.sort(Comparator.naturalOrder()); + this.loaded = true; + } catch (NoSuchFileException ignored) { + } catch (IOException e) { + LOG.warning("Failed to load schematics in " + path, e); + } finally { + lock.unlock(); + } + } + @Override void onClick() { navigateTo(this); @@ -385,8 +508,7 @@ public final class SchematicsPage extends ListPageBase { @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); @@ -394,19 +516,27 @@ public final class SchematicsPage extends ListPageBase { } } - 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); - String name = file.getName(); - if (name != null && !"Unnamed".equals(name)) { - this.name = name; + if (file == null) throw new IOException("Unsupported or deleted file: " + path); + + 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; @@ -430,12 +560,12 @@ public final class SchematicsPage extends ListPageBase { @Override int order() { - return 2; + return 1; } @Override Path getPath() { - return file.getFile(); + return path; } @Override @@ -445,7 +575,7 @@ public final class SchematicsPage extends ListPageBase { @Override String getDescription() { - return file.getFile().getFileName().toString(); + return path.getFileName().toString(); } @Override @@ -469,25 +599,25 @@ public final class SchematicsPage extends ListPageBase { @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) { @@ -497,38 +627,45 @@ public final class SchematicsPage extends ListPageBase { 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().getAsLong()); + 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); @@ -536,10 +673,15 @@ public final class SchematicsPage extends ListPageBase { { 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); } { @@ -552,15 +694,16 @@ public final class SchematicsPage extends ListPageBase { onEscPressed(this, okButton::fire); } + this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.6)); + this.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().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; @@ -568,12 +711,17 @@ public final class SchematicsPage extends ListPageBase { 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(); @@ -581,115 +729,186 @@ public final class SchematicsPage extends ListPageBase { 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)); + + var tooltip = new Tooltip(); + tooltip.textProperty().bind(isDirectoryProperty.map(b -> b ? i18n("button.reveal_dir") : i18n("reveal.in_file_manager"))); + FXUtils.installFastTooltip(btnReveal, tooltip); + } 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) { - Item oldItem = getItem(); - boolean oldEmpty = isEmpty(); + protected void updateControl(Item item, boolean empty) { + if (empty || item == null) return; - super.updateItem(item, empty); - - if (oldItem == item && oldEmpty == empty) return; - - graphics.releaseRippleImmediately(); 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); - } - - root.setRight(item instanceof BackItem ? null : right); - - setGraphic(graphics); + Path path = item.getPath(); + if (path != null) { + tooltip.setText(FileUtils.getAbsolutePath(path)); + FXUtils.installSlowTooltip(left, tooltip); + } else { + Tooltip.uninstall(left, tooltip); } } } - private final class SchematicsPageSkin extends ToolbarListPageSkin { - SchematicsPageSkin() { - super(SchematicsPage.this); + private static final class SchematicsPageSkin extends SkinBase { - StackPane placeholderContainer = new StackPane(); - placeholderContainer.getStyleClass().add("notice-pane"); - Label placeholderLabel = new Label(i18n("schematics.empty")); - placeholderContainer.getChildren().add(placeholderLabel); - listView.setPlaceholder(placeholderContainer); - } + private final JFXListView listView; - @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) - ); - } + SchematicsPageSkin(SchematicsPage skinnable) { + super(skinnable); - @Override - protected ListCell createListCell(JFXListView listView) { - return new Cell(); + 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<>()); + + { + StackPane placeholderContainer = new StackPane(); + placeholderContainer.getStyleClass().add("notice-pane"); + Label placeholderLabel = new Label(i18n("schematics.empty")); + placeholderContainer.getChildren().add(placeholderLabel); + listView.setPlaceholder(placeholderContainer); + } + + { + var toolbar = new HBox(); + JFXButton btnGoBack = createToolbarButton2("", SVG.ARROW_BACK, skinnable::navigateBack); + btnGoBack.disableProperty().bind(skinnable.isRootProperty()); + JFXButton btnDownload = createToolbarButton2(i18n("schematics.install_mod"), SVG.DOWNLOAD, skinnable::downloadLitematica); + FXUtils.onChangeAndOperate(skinnable.downloadTarget, (t) -> btnDownload.setDisable(t == null)); + 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), + btnDownload + ); + FXUtils.setOverflowHidden(toolbar, 8); + root.getContent().add(toolbar); + } + + { + 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 = new HBox(); + relPath.getStyleClass().add("jfx-tool-bar-tip"); + relPath.setAlignment(Pos.CENTER_LEFT); + FXUtils.onChangeAndOperate(skinnable.currentDirectoryProperty(), currentDir -> { + relPath.getChildren().clear(); + var d = currentDir; + while (d != null) { + if (d != currentDir) { + var box = new HBox(new Text(">")); + box.setPadding(new Insets(3)); + relPath.getChildren().add(0, box); + } + var txt = new Text(d.getName()); + var box = new HBox(txt); + box.setPadding(new Insets(3)); + box.setMouseTransparent(true); + var rippler = new RipplerContainer(box); + var finalD = d; + FXUtils.onClicked(rippler, () -> skinnable.navigateTo(finalD)); + relPath.getChildren().add(0, rippler); + d = d.parent; + } + }); + var relPathPane = new HBox(relPath); + relPathPane.setPadding(new Insets(2)); + root.getContent().add(relPathPane); + } + + pane.getChildren().setAll(root); + getChildren().setAll(pane); } } + } 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 458f40155a..c863c84380 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 @@ -29,7 +29,7 @@ import java.nio.file.Path; /// @author Glavo public enum NBTFileType { - COMPRESSED("nbt", "dat", "dat_old") { + COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") { @Override public Tag read(Path file) throws IOException { return NBTCodec.of().readTag(file); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/SynchronizedExceptionalLazy.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/SynchronizedExceptionalLazy.java new file mode 100644 index 0000000000..8b51d735d6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/SynchronizedExceptionalLazy.java @@ -0,0 +1,71 @@ +/* + * 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; + +import org.jackhuang.hmcl.util.function.ExceptionalSupplier; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Thread-safe exceptional lazy initialization wrapper. + *

+ * On failure, the result is set to null in order to allow retrying. + * + * @param value type + */ +public final class SynchronizedExceptionalLazy { + private final ExceptionalSupplier<@Nullable T, Exception> supplier; + private volatile Result result = null; + + private final ReentrantLock lock = new ReentrantLock(); + + public SynchronizedExceptionalLazy(ExceptionalSupplier<@Nullable T, Exception> supplier) { + this.supplier = Objects.requireNonNull(supplier); + } + + /// Gets and returns the result. + /// + /// If the previous attempt succeeded, the cached value is returned directly. + /// + /// Otherwise, the supplier is invoked to compute a new value.If this attempt fails, + /// {@code null} is returned; if succeeded, the computed value is cached and returned. + /// + /// @return the result, or null on failure + public T get() { + if (result != null) return result.value(); + lock.lock(); + try { + if (result == null) { + try { + result = new Result<>(supplier.get()); + } catch (Exception e) { + result = null; + return null; + } + } + return result.value(); + } finally { + lock.unlock(); + } + } + + private record Result(T value) { + } +} diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 3b0cdf1b68..099a1e624c 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -713,6 +713,10 @@ -fx-text-fill: BLACK; } +.jfx-tool-bar Text { + +} + .jfx-tool-bar .jfx-options-burger { -fx-padding: 22px; } @@ -761,6 +765,11 @@ -fx-padding: 10.0; } +.jfx-tool-bar-tip Text { + -fx-font-size: 13; + -fx-fill: -monet-on-surface; +} + .jfx-decorator-button { -fx-max-width: 40px; -fx-background-radius: 5px; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 4b7ae5b3c1..9a56febc90 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1333,7 +1333,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 @@ -1349,7 +1348,13 @@ 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, Create) +schematics.info.type.schem=Schem (Schematica, WE, MCEdit) schematics.info.version=Schematic Version +schematics.info.mc_data_version=Minecraft Data Version +schematics.install_mod=Install Litematica Mod schematics.manage=Schematics schematics.sub_items=%d sub-item(s) diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 5ef5d5eb91..635db716b6 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1333,7 +1333,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=فشل إنشاء المجلد @@ -1349,7 +1348,13 @@ schematics.info.time_created=وقت الإنشاء schematics.info.time_modified=وقت التعديل schematics.info.total_blocks=إجمالي البلوكات schematics.info.total_volume=الحجم الكلي +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= schematics.info.version=إصدار المخطط +# schematics.info.mc_data_version= +# schematics.install_mod= schematics.manage=المخططات schematics.sub_items=%d عنصر فرعي diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index 50fc734e7c..0e3534cb79 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -1332,7 +1332,6 @@ schematics=Schematiken schematics.add=Hinzufügen schematics.add.failed=Fehler beim Hinzufügen der Schematik-Dateien schematics.add.title=Wählen Sie die Schematik-Datei, die Sie importieren möchten -schematics.back_to=Zurück zu "%s" schematics.create_directory=Neues Verzeichnis schematics.create_directory.prompt=Bitte geben Sie den neuen Verzeichnisnamen ein schematics.create_directory.failed=Fehler beim Erstellen des Verzeichnisses @@ -1348,7 +1347,13 @@ schematics.info.time_created=Erstellungszeit schematics.info.time_modified=Änderungszeit schematics.info.total_blocks=Gesamte Blöcke schematics.info.total_volume=Gesamtes Volumen +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= schematics.info.version=Schematik-Version +# schematics.info.mc_data_version= +# schematics.install_mod= schematics.manage=Schematiken schematics.sub_items=%d Unterelement(e) diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 6975888575..38d32da099 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1325,7 +1325,6 @@ schematics=Esquemas # schematics.add= schematics.add.failed=No se han podido añadir archivos de esquema # schematics.add.title= -schematics.back_to=Atrás a «%s» # schematics.create_directory= schematics.create_directory.prompt=Introduzca el nuevo nombre del directorio schematics.create_directory.failed=No se ha podido crear el directorio @@ -1341,7 +1340,13 @@ schematics.info.time_created=Creado schematics.info.time_modified=Modificado schematics.info.total_blocks=Bloques totales schematics.info.total_volume=Volumen total +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= schematics.info.version=Versión de esquema +# schematics.info.mc_data_version= +# schematics.install_mod= schematics.manage=Esquemas schematics.sub_items=%d elemento(s) diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 68b3448901..d6f3e2fc5d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -1129,7 +1129,6 @@ reveal.in_file_manager=ファイルマネージャーで表示する # schematics.add= # schematics.add.failed= # schematics.add.title= -# schematics.back_to= # schematics.create_directory= # schematics.create_directory.prompt= # schematics.create_directory.failed= @@ -1145,7 +1144,13 @@ reveal.in_file_manager=ファイルマネージャーで表示する # schematics.info.time_modified= # schematics.info.total_blocks= # schematics.info.total_volume= +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= # schematics.info.version= +# schematics.info.mc_data_version= +# schematics.install_mod= # schematics.manage= # schematics.sub_items= diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 1160643e74..0abaf77bbf 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -1141,7 +1141,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=增案夾未成 @@ -1157,7 +1156,13 @@ schematics.info.time_created=創之時辰 schematics.info.time_modified=改之時辰 schematics.info.total_blocks=總塊數 schematics.info.total_volume=總體積 +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= schematics.info.version=蜃圖之版 +# schematics.info.mc_data_version= +# schematics.install_mod= schematics.manage=司蜃圖 schematics.sub_items=%d 子项 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 5d140d55c9..8b75ae658b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1265,7 +1265,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=Не удалось создать папку @@ -1281,7 +1280,13 @@ schematics.info.time_created=Создан schematics.info.time_modified=Изменен schematics.info.total_blocks=Всего блоков schematics.info.total_volume=Общий объём +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= schematics.info.version=Версия схемы +# schematics.info.mc_data_version= +# schematics.install_mod= schematics.manage=Схемы schematics.sub_items=%d элемент(ов) diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index b9bcc58adc..6f1985bff0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1288,7 +1288,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=Не вдалося створити каталог @@ -1304,7 +1303,13 @@ schematics.info.time_created=Час створення schematics.info.time_modified=Час зміни schematics.info.total_blocks=Всього блоків schematics.info.total_volume=Загальний об'єм +# schematics.info.type= +# schematics.info.type.litematic= +# schematics.info.type.nbt_structure= +# schematics.info.type.schem= schematics.info.version=Версія схематики +# schematics.info.mc_data_version= +# schematics.install_mod= schematics.manage=Схематики schematics.sub_items=%d підпунктів diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index 546ea7822c..be22b30fc3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -1145,7 +1145,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=创建文件夹失败 @@ -1161,7 +1160,13 @@ 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.install_mod=安装投影模组 schematics.manage=原理图管理 schematics.sub_items=%d 个子项 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index 27f7eb9b96..458f32c556 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -1139,7 +1139,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=建立目錄失敗 @@ -1155,7 +1154,13 @@ 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.install_mod=安裝投影模組 schematics.manage=原理圖管理 schematics.sub_items=%d 個子項 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index e89abc3e38..f36d7edf84 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.addon.meta.*; import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameComponentType; +import org.jackhuang.hmcl.game.NoSuchGameInstanceException; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; @@ -65,7 +66,9 @@ public final class ModManager extends LocalAddonManager { READERS = map; } + private String gameVersion; private final HashMap, LocalMod> localMods = new HashMap<>(); + private final EnumSet supportedLoaders = EnumSet.noneOf(ModLoaderType.class); private GameComponentAnalyzer analyzer; private boolean loaded = false; @@ -83,7 +86,12 @@ public final class ModManager extends LocalAddonManager { } public GameComponentAnalyzer getComponentAnalyzer() { - return analyzer; + lock.lock(); + try { + return analyzer; + } finally { + lock.unlock(); + } } public LocalMod getLocalMod(String modId, ModLoaderType modLoaderType) { @@ -105,6 +113,24 @@ public final class ModManager extends LocalAddonManager { } } + public String getGameVersion() { + lock.lock(); + try { + return gameVersion; + } finally { + lock.unlock(); + } + } + + public EnumSet getSupportedLoaders() { + lock.lock(); + try { + return EnumSet.copyOf(supportedLoaders); + } finally { + lock.unlock(); + } + } + private void addModInfo(Path file) { String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); String extension = fileName.substring(fileName.lastIndexOf(".") + 1); @@ -176,6 +202,18 @@ public final class ModManager extends LocalAddonManager { } } + public void analyze() throws IOException { + lock.lock(); + try { + gameVersion = instance.getVersion().toString(); + analyzer = instance.getAnalyzer(); + } catch (NoSuchGameInstanceException e) { + throw new IOException(e); + } finally { + lock.unlock(); + } + } + @Override public void refresh() throws IOException { lock.lock(); @@ -183,7 +221,7 @@ public final class ModManager extends LocalAddonManager { localFiles.clear(); localMods.clear(); - analyzer = instance.getAnalyzer(); + analyze(); boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.QUILT) @@ -205,6 +243,9 @@ public final class ModManager extends LocalAddonManager { } } } + + updateSupportedLoaders(); + loaded = true; } finally { lock.unlock(); @@ -254,6 +295,50 @@ public final class ModManager extends LocalAddonManager { } } + private void updateSupportedLoaders() { + supportedLoaders.clear(); + + if (this.analyzer == null) { + Collections.addAll(supportedLoaders, ModLoaderType.values()); + return; + } + + for (GameComponentType type : GameComponentType.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(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { + supportedLoaders.add(ModLoaderType.FORGE); + } + + if (this.analyzer.has(GameComponentType.QUILT)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + + if (this.analyzer.has(GameComponentType.LEGACY_FABRIC)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + + if (this.analyzer.has(GameComponentType.FABRIC) && hasMod("kilt", ModLoaderType.FABRIC)) { + supportedLoaders.add(ModLoaderType.FORGE); + supportedLoaders.add(ModLoaderType.NEO_FORGE); + } + + // Sinytra Connector + if (this.analyzer.has(GameComponentType.NEO_FORGE) && (hasMod("connector", ModLoaderType.NEO_FORGE) || hasMod("connectormod", ModLoaderType.NEO_FORGE)) + || "1.20.1".equals(gameVersion) && this.analyzer.has(GameComponentType.FORGE) && hasMod("connectormod", ModLoaderType.FORGE)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + } + public void rollback(LocalModFile from, LocalModFile to) throws IOException { lock.lock(); try { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java index a558a8792b..0b6a6f88ae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -18,17 +18,23 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.schematic.LitematicaConfig; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.EnumSet; import java.util.Optional; import java.util.Set; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + /// Provides a view of a game instance and its instance-specific paths within a /// [GameRepositorySnapshot]. /// @@ -160,7 +166,25 @@ public interface GameInstance { /// /// @return the schematics directory below the run directory default Path getSchematicsDirectory() { - return getRunDirectory().resolve("schematics"); + var runDir = getRunDirectory(); + var litematicaConfig = runDir.resolve("config").resolve("litematica.json"); + Path dir = runDir.resolve("schematics"); + if (Files.isRegularFile(litematicaConfig)) { + try { + var conf = JsonUtils.fromJsonFile(litematicaConfig, 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.normalize() : runDir.resolve(p).normalize(); + } + } catch (Exception e) { + LOG.warning("Failed to load custom schematics directory from litematica config at '%s'".formatted(litematicaConfig), e); + } + } + return dir; } /// Returns the directory used for extracted native libraries for a platform. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModAdviser.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModAdviser.java index 7c83c6bab7..16f3877273 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModAdviser.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModAdviser.java @@ -59,7 +59,6 @@ public interface ModAdviser { "irisUpdateInfo.json", // Iris "modernfix", // ModernFix "modtranslations", // Mod translations - "schematics", // Schematics mod "journeymap/data", // JourneyMap "mods/.connector" // Sinytra Connector ); @@ -70,6 +69,7 @@ public interface ModAdviser { "blueprints" /* BuildCraft */, "optionsof.txt" /* OptiFine */, "journeymap" /* JourneyMap */, + "schematics" /* Schematics mod */, "optionsshaders.txt", "mods/VoxelMods"); 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 e47feafde9..9e07793142 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java @@ -17,33 +17,31 @@ */ package org.jackhuang.hmcl.schematic; -import javafx.geometry.Point3D; -import org.glavo.nbt.io.NBTCodec; -import org.glavo.nbt.tag.*; +import org.glavo.nbt.tag.CompoundTag; +import org.glavo.nbt.tag.IntArrayTag; +import org.glavo.nbt.tag.IntTag; +import org.glavo.nbt.tag.Tag; +import org.jackhuang.hmcl.util.Point3I; +import org.jackhuang.hmcl.util.StringUtils; 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; +import java.util.OptionalLong; -/// @author Glavo -/// @see The Litematic file format -public final class LitematicFile { +import static org.jackhuang.hmcl.util.NBTUtils.*; - private static @Nullable String tryGetString(Tag tag) { - return tag instanceof StringTag stringTag ? stringTag.get() : null; - } +/** + * @author Glavo + * @see The Litematic file format + */ +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 = NBTCodec.of().readTag(in, TagType.COMPOUND); - } + CompoundTag root = readCompressed(file); Tag versionTag = root.get("Version"); if (versionTag == null) @@ -61,19 +59,29 @@ public final class LitematicFile { if (root.get("Regions") instanceof CompoundTag regionsTag) regions = 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(), - root.getIntOrZero("SubVersion"), - root.getIntOrZero("MinecraftDataVersion"), - regions + ((IntTag) versionTag).get(), + 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; @@ -83,14 +91,12 @@ public final class LitematicFile { 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"); @@ -101,48 +107,39 @@ public final class LitematicFile { this.name = tryGetString(metadata.get("Name")); this.author = tryGetString(metadata.get("Author")); this.description = tryGetString(metadata.get("Description")); - this.timeCreated = metadata.get("TimeCreated") instanceof LongTag time ? Instant.ofEpochMilli(time.getValue()) : null; - this.timeModified = metadata.get("TimeModified") instanceof LongTag time ? Instant.ofEpochMilli(time.getValue()) : null; - this.totalBlocks = metadata.getIntOrZero("TotalBlocks"); - this.totalVolume = metadata.getIntOrZero("TotalVolume"); - - Point3D enclosingSize = null; - if (metadata.get("EnclosingSize") instanceof CompoundTag list) { - int x = list.getIntOrZero("x"); - int y = list.getIntOrZero("y"); - int z = list.getIntOrZero("z"); - - if (x >= 0 && y >= 0 && z >= 0) - enclosingSize = new Point3D(x, y, z); - } - this.enclosingSize = enclosingSize; + this.timeCreated = tryGetLongTimestamp(metadata.get("TimeCreated")); + this.timeModified = tryGetLongTimestamp(metadata.get("TimeModified")); + 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 subVersion >= 0 ? OptionalInt.of(subVersion) : OptionalInt.empty(); } + @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; } @@ -151,27 +148,29 @@ public final class LitematicFile { return description; } + @Override public Instant getTimeCreated() { return timeCreated; } + @Override public Instant getTimeModified() { return timeModified; } - public int getTotalBlocks() { - return totalBlocks; + @Override + public OptionalInt getTotalBlocks() { + return totalBlocks >= 1 ? OptionalInt.of(totalBlocks) : OptionalInt.empty(); } - public int getTotalVolume() { - return totalVolume; + @Override + public OptionalLong getTotalVolume() { + return totalVolume >= 1 ? OptionalLong.of(totalVolume) : OptionalLong.empty(); } - public Point3D getEnclosingSize() { - return enclosingSize; + @Override + public OptionalInt getRegionCount() { + return regionCount >= 1 ? OptionalInt.of(regionCount) : OptionalInt.empty(); } - 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..ab20f6d5f8 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/NBTStructureFile.java @@ -0,0 +1,83 @@ +/* + * 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.glavo.nbt.tag.CompoundTag; +import org.glavo.nbt.tag.ListTag; +import org.glavo.nbt.tag.Tag; +import org.jackhuang.hmcl.util.NBTUtils; +import org.jackhuang.hmcl.util.Point3I; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.NBTUtils.tryGetInt; + +/// @author Calboot +/// @see Structure File +public final class NBTStructureFile extends Schematic { + + public static NBTStructureFile load(Path file) throws IOException { + + CompoundTag root = NBTUtils.readCompressed(file); + + if (!(root.get("blocks") instanceof ListTag)) + throw new IOException("blocks tag not found"); + if (!(root.get("entities") instanceof ListTag)) + throw new IOException("entities tag not found"); + + 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"); + ListTag size = (ListTag) sizeTag; + if (size.size() != 3) + throw new IOException("size tag does not have 3 elements"); + Tag xTag = size.getTag(0); + Tag yTag = size.getTag(1); + Tag zTag = size.getTag(2); + Point3I enclosingSize = 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, tryGetInt(root.get("DataVersion")).orElse(500), NBTUtils.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..69893f4994 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/SchemFile.java @@ -0,0 +1,152 @@ +/* + * 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.glavo.nbt.tag.CompoundTag; +import org.glavo.nbt.tag.IntTag; +import org.glavo.nbt.tag.StringTag; +import org.glavo.nbt.tag.Tag; +import org.jackhuang.hmcl.util.NBTUtils; +import org.jackhuang.hmcl.util.Point3I; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.OptionalInt; + +import static org.jackhuang.hmcl.util.NBTUtils.tryGetShort; +import static org.jackhuang.hmcl.util.NBTUtils.tryGetString; + +/// @author Calboot +/// @see Schematic File Format Wiki +/// @see Schematica +/// @see Sponge Spec +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 = NBTUtils.readCompressed(file); + + if (root.get("Materials") != null) return loadLegacy(file, root); + else if (root.get("Version") != null) return loadSponge(file, root); + else { + var schem = root.get("Schematic"); + if (schem instanceof CompoundTag sponge3 && sponge3.get("Version") != null) { + return loadSponge(file, sponge3); + } + } + 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).orElse((short) 0); + short height = tryGetShort(heightTag).orElse((short) 0); + short length = tryGetShort(lengthTag).orElse((short) 0); + if (width > 0 && height > 0 && length > 0) { + enclosingSize = new Point3I(width, height, length); + } + } + + return new SchemFile(file, null, null, ((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; + dataVersion = dataVersionTag == null ? DATA_VERSION_MC_1_13_2 : NBTUtils.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).orElse((short) 0) & 0xFFFF; + int height = tryGetShort(heightTag).orElse((short) 0) & 0xFFFF; + int length = tryGetShort(lengthTag).orElse((short) 0) & 0xFFFF; + if (width > 0 && height > 0 && length > 0) { + enclosingSize = new Point3I(width, height, length); + } + } + + Tag metaTag = root.get("Metadata"); + String name = null, author = null; + if (metaTag instanceof CompoundTag meta) { + name = tryGetString(meta.get("Name")); + author = tryGetString(meta.get("Author")); + } + + return new SchemFile(file, name, author, null, dataVersion, ((IntTag) versionTag).getValue(), enclosingSize); + } + + private final @Nullable String name; + private final @Nullable String author; + private final @Nullable String materials; + private final int version; + + private SchemFile(Path file, @Nullable String name, @Nullable String author, @Nullable String materials, int dataVersion, int version, Point3I enclosingSize) { + super(file, dataVersion, enclosingSize); + this.name = name; + this.author = author; + this.materials = materials; + this.version = version; + } + + @Override + public @NotNull String getName() { + if (name == null) return super.getName(); + return name; + } + + @Override + public String getAuthor() { + if (author == null) return super.getAuthor(); + return author; + } + + @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..e20c451e2a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/Schematic.java @@ -0,0 +1,128 @@ +/* + * 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.Point3I; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.OptionalInt; +import java.util.OptionalLong; + +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); + }; + } + + 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() { + /* 15w32a */ + return dataVersion >= 100 ? OptionalInt.of(dataVersion) : OptionalInt.empty(); + } + + @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 OptionalLong getTotalVolume() { + var enclosingSize = getEnclosingSize(); + if (enclosingSize != null) return OptionalLong.of((long) enclosingSize.x() * enclosingSize.y() * enclosingSize.z()); + return OptionalLong.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/NBTUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/NBTUtils.java new file mode 100644 index 0000000000..7bd853f759 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/NBTUtils.java @@ -0,0 +1,79 @@ +/* + * 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; + +import org.glavo.nbt.io.NBTCodec; +import org.glavo.nbt.tag.*; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; + +public final class NBTUtils { + + public static CompoundTag readCompressed(Path file) throws IOException { + return NBTCodec.of().readTag(file, TagType.COMPOUND); + } + + public static OptionalLong tryGetLong(Tag tag) { + if (tag instanceof ValueTag valueTag) { + Object val = valueTag.getValue(); + if (val instanceof Long || val instanceof Integer || val instanceof Short || val instanceof Byte) { + return OptionalLong.of(((Number) val).longValue()); + } + } + return OptionalLong.empty(); + } + + public static OptionalInt tryGetInt(Tag tag) { + var ol = tryGetLong(tag); + if (ol.isEmpty()) return OptionalInt.empty(); + long l = ol.getAsLong(); + int i = (int) l; + if (l == i) return OptionalInt.of(i); + return OptionalInt.empty(); + } + + public static Optional tryGetShort(Tag tag) { + var ol = tryGetLong(tag); + if (ol.isEmpty()) return Optional.empty(); + long l = ol.getAsLong(); + short i = (short) l; + if (l == i) return Optional.of(i); + return Optional.empty(); + } + + public static @Nullable Instant tryGetLongTimestamp(Tag tag) { + if (tag instanceof LongTag longTag) { + return Instant.ofEpochMilli(longTag.get()); + } + return null; + } + + public static @Nullable String tryGetString(Tag tag) { + return tag instanceof StringTag stringTag ? stringTag.get() : null; + } + + private NBTUtils() { + } + +} 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