diff --git a/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java b/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java index 8c0b95929..329dc6ce1 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java +++ b/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java @@ -35,27 +35,34 @@ import java.util.Locale; record BiomePalette(String biomeKey, Kind kind, int index) { private static final String ID_PREFIX = "palette:"; private static final char ID_SEPARATOR = '#'; + private static final String SNIPPET_PREFIX = "snippet/"; enum Kind { - LAYER("Layer", "layers", true, true), - SEA_LAYER("Sea layer", "seaLayers", true, true), - CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", true, true), - WALL("Wall", "wall", false, false), - SLAB("Slab", "slab", false, false), - DECORATOR("Decorator", "decorators", false, true); + LAYER("Layer", "layers", SnippetFolder.BLOCK_PALETTES, true, true), + SEA_LAYER("Sea layer", "seaLayers", SnippetFolder.BLOCK_PALETTES, true, true), + CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", SnippetFolder.BLOCK_PALETTES, true, true), + WALL("Wall", "wall", SnippetFolder.BLOCK_PALETTES, false, false), + SLAB("Slab", "slab", SnippetFolder.BLOCK_PALETTES, false, false), + DECORATOR("Decorator", "decorators", SnippetFolder.DECORATORS, false, true); private final String label; private final String jsonKey; + private final SnippetFolder snippetFolder; private final boolean stacked; private final boolean numbered; - Kind(String label, String jsonKey, boolean stacked, boolean numbered) { + Kind(String label, String jsonKey, SnippetFolder snippetFolder, boolean stacked, boolean numbered) { this.label = label; this.jsonKey = jsonKey; + this.snippetFolder = snippetFolder; this.stacked = stacked; this.numbered = numbered; } + SnippetFolder snippetFolder() { + return snippetFolder; + } + /** * Whether the palette is a stack of blocks with a thickness, rather than single blocks on walls or slopes. */ @@ -93,6 +100,38 @@ record BiomePalette(String biomeKey, Kind kind, int index) { } } + /** + * The folders under snippet/ that palettes can come from, named after the Iris type they hold. + */ + enum SnippetFolder { + BLOCK_PALETTES("biome-palette", "Block palettes"), + DECORATORS("decorator", "Decorators"); + + private final String folder; + private final String label; + + SnippetFolder(String folder, String label) { + this.folder = folder; + this.label = label; + } + + String folder() { + return folder; + } + + String label() { + return label; + } + + /** + * The snippet's path inside this folder. Packs may leave the folder out, the way the snippet loader allows. + */ + String pathOf(String reference) { + String path = reference.startsWith(SNIPPET_PREFIX) ? reference.substring(SNIPPET_PREFIX.length()) : reference; + return path.startsWith(folder + "/") ? path.substring(folder.length() + 1) : path; + } + } + static List allOf(String biomeKey, IrisBiome biome) { List palettes = new ArrayList<>(); for (Kind kind : Kind.values()) { diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java index 538c87d0c..f6c6349f0 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java +++ b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java @@ -43,7 +43,7 @@ final class BlockTextures { private final ZipFile clientJar; private final MinecraftBiomeColors biomeColors; - private final Map> topTextures = new ConcurrentHashMap<>(); + private final Map> resolvedTextures = new ConcurrentHashMap<>(); private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) { this.clientJar = clientJar; @@ -71,12 +71,27 @@ final class BlockTextures { return biomeColors; } + /** + * What the block looks like from above. Plants and other side-on sprites become their average colour, because + * their sprite would read as stripes across a stack of ground blocks. + */ Optional topTexture(BlockData block) { - // Blockstate files match on every property, so defaults the pack left out have to be spelled out too. - return topTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture); + return resolve(block).map(resolved -> resolved.isSideOnSprite() ? resolved.texture().flattened() : resolved.texture()); } - private Optional resolveTopTexture(String blockState) { + /** + * Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable. + */ + Optional iconTexture(BlockData block) { + return resolve(block).map(ResolvedTexture::texture); + } + + // Blockstate files match on every property, so defaults the pack left out have to be spelled out too. + private Optional resolve(BlockData block) { + return resolvedTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture); + } + + private Optional resolveTopTexture(String blockState) { try { BlockStateId state = BlockStateId.parse(blockState); JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json")); @@ -97,7 +112,7 @@ final class BlockTextures { MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()), MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null) ); - return Optional.of(face.isSideOnSprite() ? texture.flattened() : texture); + return Optional.of(new ResolvedTexture(texture, face.isSideOnSprite())); } catch (IOException | RuntimeException error) { return Optional.empty(); } @@ -284,6 +299,9 @@ final class BlockTextures { private record ModelFace(String texture, int tintIndex, boolean isSideOnSprite) { } + private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) { + } + private record BlockStateId(String namespace, String name, Map properties) { private static BlockStateId parse(String blockState) { int propertiesStart = blockState.indexOf('['); diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java index 62ded2dbe..0a8d40c48 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java @@ -35,6 +35,7 @@ import java.io.FileReader; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -43,7 +44,8 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -record PaletteCatalog(List regions, List biomesOutsideRegions, Map biomes) { +record PaletteCatalog(List regions, List biomesOutsideRegions, Map biomes, + List snippets) { private static final int SUMMARIZED_BLOCK_COUNT = 3; static PaletteCatalog load(Engine engine) { @@ -61,7 +63,33 @@ record PaletteCatalog(List regions, List biomesOutsideRegio entry.groups().forEach(group -> group.biomeKeys().forEach(key -> collectWithChildren(key, biomes, biomesInRegions))); } List outside = biomes.keySet().stream().filter(key -> !biomesInRegions.contains(key)).toList(); - return new PaletteCatalog(List.copyOf(regions), outside, Map.copyOf(biomes)); + return new PaletteCatalog(List.copyOf(regions), outside, Map.copyOf(biomes), loadSnippets(data, biomes)); + } + + // Snippet files no biome refers to are listed too, so a new snippet shows up before it is put to use. + private static List loadSnippets(IrisData data, Map biomes) { + Map snippets = new LinkedHashMap<>(); + for (BiomeEntry biome : biomes.values()) { + for (PaletteSummary summary : biome.palettes()) { + if (summary.snippet() == null) { + continue; + } + BiomePalette.SnippetFolder folder = summary.palette().kind().snippetFolder(); + snippets.computeIfAbsent(folder.folder() + "/" + summary.snippet(), + key -> new SnippetEntry(folder, summary.snippet(), summary.blocks(), new ArrayList<>())) + .uses().add(summary.palette()); + } + } + for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) { + for (String reference : data.getPossibleSnippets(folder.folder())) { + String path = folder.pathOf(reference); + snippets.putIfAbsent(folder.folder() + "/" + path, new SnippetEntry(folder, path, null, List.of())); + } + } + return snippets.values().stream() + .map(snippet -> new SnippetEntry(snippet.folder(), snippet.path(), snippet.blocks(), List.copyOf(snippet.uses()))) + .sorted(Comparator.comparing(SnippetEntry::folder).thenComparing(SnippetEntry::path)) + .toList(); } // A studio focus region is generated even when the dimension's region list doesn't include it. @@ -143,8 +171,7 @@ record PaletteCatalog(List regions, List biomesOutsideRegio if (element == null || !element.isJsonPrimitive()) { return null; } - String reference = element.getAsString(); - return reference.substring(reference.lastIndexOf('/') + 1); + return palette.kind().snippetFolder().pathOf(element.getAsString()); } private static JsonObject readSource(File file) { @@ -211,7 +238,20 @@ record PaletteCatalog(List regions, List biomesOutsideRegio } /** - * @param snippet the name of the snippet file the palette comes from, or null when the biome defines it itself + * A snippet file palettes can come from. + * + * @param blocks a summary of its blocks, or null when no biome uses it + * @param uses the palettes made from it, in biome order + */ + record SnippetEntry(BiomePalette.SnippetFolder folder, String path, String blocks, List uses) { + boolean matches(String filter) { + return path.toLowerCase(Locale.ROOT).contains(filter) + || (blocks != null && blocks.toLowerCase(Locale.ROOT).contains(filter)); + } + } + + /** + * @param snippet the path of the snippet file the palette comes from, or null when the biome defines it itself */ record PaletteSummary(BiomePalette palette, String snippet, String blocks) { String text() { diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java index ad206e41f..0cbd7d2c6 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java @@ -31,6 +31,7 @@ import org.bukkit.block.data.BlockData; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; final class PaletteLayerPreview { static final int HISTOGRAM_BINS = 50; @@ -206,11 +207,15 @@ final class PaletteLayerPreview { } for (int entry = 0; entry < blocks.size(); entry++) { BlockData block = blocks.get(entry).getBlockData(data); - resolved[entry] = block == null ? null : textures.topTexture(block).orElse(null); + resolved[entry] = block == null ? null : textureOf(block, textures).orElse(null); } return resolved; } + private Optional textureOf(BlockData block, BlockTextures textures) { + return palette.kind() == BiomePalette.Kind.DECORATOR ? textures.iconTexture(block) : textures.topTexture(block); + } + // The legend shows tinted blocks as they look at the world origin, where the statistics are sampled too. private BlockTexture[] tintForLegend(BlockTexture[] textures) { BlockTexture[] tinted = new BlockTexture[textures.length]; diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java index b717bf278..546c24abc 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java @@ -154,7 +154,7 @@ final class PaletteNavigator extends JPanel { private JPanel buildOverviewCard() { JPanel card = new JPanel(new BorderLayout()); - StudioToolbar.makeSearchField(searchField, "Search biomes or blocks..."); + StudioToolbar.makeSearchField(searchField, "Search biomes, snippets or blocks..."); searchField.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent event) { @@ -310,6 +310,7 @@ final class PaletteNavigator extends JPanel { outside.setUserObject(new OverviewNode("Not in any region", outside.getChildCount() + " biomes", null, null)); root.add(outside); } + addSnippetNodes(root, filter); updatingSelection = true; try { treeModel.setRoot(root); @@ -340,6 +341,38 @@ final class PaletteNavigator extends JPanel { } } + // Snippets sit below the regions, so a palette picked elsewhere is still found in its biome first. + private void addSnippetNodes(DefaultMutableTreeNode root, String filter) { + for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) { + DefaultMutableTreeNode folderNode = new DefaultMutableTreeNode(); + catalog.snippets().stream() + .filter(snippet -> snippet.folder() == folder && (filter.isEmpty() || snippet.matches(filter))) + .forEach(snippet -> folderNode.add(snippetNode(snippet))); + if (folderNode.getChildCount() > 0) { + folderNode.setUserObject(new OverviewNode(folder.label() + " snippets", folderNode.getChildCount() + " files", null, null)); + root.add(folderNode); + } + } + } + + // Picking a snippet opens it where the first biome using it places it; its children open the other uses. + private DefaultMutableTreeNode snippetNode(PaletteCatalog.SnippetEntry snippet) { + if (snippet.uses().isEmpty()) { + return new DefaultMutableTreeNode(new OverviewNode(snippet.path(), "not used by any biome", null, null)); + } + BiomePalette firstUse = snippet.uses().get(0); + String usedBy = snippet.uses().size() == 1 ? "used once" : "used " + snippet.uses().size() + " times"; + DefaultMutableTreeNode node = new DefaultMutableTreeNode(new OverviewNode(snippet.path() + " · " + snippet.blocks(), + usedBy, firstUse.biomeKey(), firstUse)); + for (BiomePalette use : snippet.uses()) { + BiomeEntry biome = catalog.biome(use.biomeKey()); + String biomeName = biome == null ? use.biomeKey() : biome.name(); + node.add(new DefaultMutableTreeNode(new OverviewNode(biomeName + " · " + use.lowercaseLabel(), use.biomeKey(), + use.biomeKey(), use))); + } + return node; + } + private void expandDownToBiomes(DefaultMutableTreeNode root) { Enumeration nodes = root.breadthFirstEnumeration(); while (nodes.hasMoreElements()) { @@ -351,6 +384,9 @@ final class PaletteNavigator extends JPanel { } private void selectInTree() { + if (selectedNodeShowsSelectedPalette()) { + return; + } DefaultMutableTreeNode match = findLayerNode((DefaultMutableTreeNode) treeModel.getRoot()); if (match == null) { overviewTree.clearSelection(); @@ -365,6 +401,13 @@ final class PaletteNavigator extends JPanel { } } + // The same palette can be listed under its biome and under its snippet; the row the user picked stays picked. + private boolean selectedNodeShowsSelectedPalette() { + return nodeAt(overviewTree.getSelectionPath()) instanceof OverviewNode node + && node.palette() != null + && node.palette().equals(selectedPalette); + } + private DefaultMutableTreeNode findLayerNode(DefaultMutableTreeNode root) { Enumeration nodes = root.depthFirstEnumeration(); while (nodes.hasMoreElements()) { @@ -477,7 +520,8 @@ final class PaletteNavigator extends JPanel { Object userObject = ((DefaultMutableTreeNode) value).getUserObject(); String text = userObject instanceof OverviewNode node ? node.html() : ""; super.getTreeCellRendererComponent(tree, text, selected, expanded, leaf, row, false); - setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? StudioTheme.boldFont() : StudioTheme.font()); + boolean groupsOthers = userObject instanceof OverviewNode node && node.biomeKey() == null && !leaf; + setFont(groupsOthers ? StudioTheme.boldFont() : StudioTheme.font()); return this; } }