From e57460b76ba072a971736bb0e1e788f18a81fc20 Mon Sep 17 00:00:00 2001 From: Eclipse Date: Sat, 6 Sep 2025 08:50:15 +0000 Subject: [PATCH] Include PackConverter & Creative, create Creative pack wrapper for Minecraft's ResourceManager --- build.gradle.kts | 16 ++ gradle.properties | 1 + .../java/org/geysermc/rainbow/KeyUtil.java | 16 ++ .../java/org/geysermc/rainbow/Rainbow.java | 75 ++++++ .../CachingStreamResourceContainer.java | 42 +++ .../creative/ImmutableResourceContainer.java | 140 ++++++++++ .../creative/ImmutableResourcePack.java | 37 +++ .../MinecraftCreativeResourcePack.java | 70 +++++ .../creative/StreamResourceContainer.java | 254 ++++++++++++++++++ 9 files changed, 651 insertions(+) create mode 100644 src/main/java/org/geysermc/rainbow/KeyUtil.java create mode 100644 src/main/java/org/geysermc/rainbow/creative/CachingStreamResourceContainer.java create mode 100644 src/main/java/org/geysermc/rainbow/creative/ImmutableResourceContainer.java create mode 100644 src/main/java/org/geysermc/rainbow/creative/ImmutableResourcePack.java create mode 100644 src/main/java/org/geysermc/rainbow/creative/MinecraftCreativeResourcePack.java create mode 100644 src/main/java/org/geysermc/rainbow/creative/StreamResourceContainer.java diff --git a/build.gradle.kts b/build.gradle.kts index 716b807..bf3e7a2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,9 @@ val loaderVersion = properties["loader_version"]!! as String val modVersion = properties["mod_version"]!! as String val supportedVersions = properties["supported_versions"]!! as String val archivesBaseName = properties["archives_base_name"]!! as String + val fabricVersion = properties["fabric_version"]!! as String +val packConverterVersion = properties["pack_converter_version"]!! as String val targetJavaVersion = 21 @@ -18,6 +20,16 @@ repositories { name = "ParchmentMC" url = uri("https://maven.parchmentmc.org") } + + maven { + name = "Jitpack" + url = uri("https://jitpack.io") + } + + maven { + name = "Open Collaboration" + url = uri("https://repo.opencollab.dev/main") + } } dependencies { @@ -30,6 +42,10 @@ dependencies { modImplementation("net.fabricmc:fabric-loader:${loaderVersion}") modImplementation("net.fabricmc.fabric-api:fabric-api:${fabricVersion}") + + include(implementation("com.github.GeyserMC.unnamed-creative:creative-api:817fa982c4")!!) + include(implementation("com.github.GeyserMC.unnamed-creative:creative-serializer-minecraft:817fa982c4")!!) + include(implementation("org.geysermc.pack:converter:${packConverterVersion}")!!) } tasks { diff --git a/gradle.properties b/gradle.properties index 79e8f2f..4dca00d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,3 +13,4 @@ archives_base_name=rainbow # Dependencies fabric_version=0.135.0+1.21.10 +pack_converter_version=3.4.1-SNAPSHOT diff --git a/src/main/java/org/geysermc/rainbow/KeyUtil.java b/src/main/java/org/geysermc/rainbow/KeyUtil.java new file mode 100644 index 0000000..5811cfc --- /dev/null +++ b/src/main/java/org/geysermc/rainbow/KeyUtil.java @@ -0,0 +1,16 @@ +package org.geysermc.rainbow; + +import net.kyori.adventure.key.Key; +import net.minecraft.resources.ResourceLocation; + +@SuppressWarnings("PatternValidation") +public interface KeyUtil { + + static Key resourceLocationToKey(ResourceLocation location) { + return Key.key(location.getNamespace(), location.getPath()); + } + + static ResourceLocation keyToResourceLocation(Key key) { + return ResourceLocation.fromNamespaceAndPath(key.namespace(), key.value()); + } +} diff --git a/src/main/java/org/geysermc/rainbow/Rainbow.java b/src/main/java/org/geysermc/rainbow/Rainbow.java index f0f4eba..d14f252 100644 --- a/src/main/java/org/geysermc/rainbow/Rainbow.java +++ b/src/main/java/org/geysermc/rainbow/Rainbow.java @@ -2,16 +2,39 @@ package org.geysermc.rainbow; import com.mojang.logging.LogUtils; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.command.v2.ArgumentTypeRegistry; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; import net.minecraft.commands.synchronization.SingletonArgumentInfo; +import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import org.geysermc.pack.bedrock.resource.BedrockResourcePack; +import org.geysermc.pack.converter.PackConversionContext; +import org.geysermc.pack.converter.PackConverter; +import org.geysermc.pack.converter.converter.ActionListener; +import org.geysermc.pack.converter.converter.Converter; +import org.geysermc.pack.converter.converter.base.PackManifestConverter; +import org.geysermc.pack.converter.converter.lang.LangConverter; +import org.geysermc.pack.converter.converter.misc.SplashTextConverter; +import org.geysermc.pack.converter.converter.model.ModelConverter; +import org.geysermc.pack.converter.data.ConversionData; +import org.geysermc.pack.converter.util.DefaultLogListener; +import org.geysermc.pack.converter.util.LogListener; import org.geysermc.rainbow.command.CommandSuggestionsArgumentType; import org.geysermc.rainbow.command.PackGeneratorCommand; +import org.geysermc.rainbow.creative.MinecraftCreativeResourcePack; import org.geysermc.rainbow.mapper.PackMapper; import org.slf4j.Logger; +import javax.imageio.ImageIO; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + public class Rainbow implements ClientModInitializer { public static final String MOD_ID = "rainbow"; @@ -29,6 +52,58 @@ public class Rainbow implements ClientModInitializer { ArgumentTypeRegistry.registerArgumentType(getModdedLocation("command_suggestions"), CommandSuggestionsArgumentType.class, SingletonArgumentInfo.contextFree(CommandSuggestionsArgumentType::new)); + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, buildContext) -> { + dispatcher.register(ClientCommandManager.literal("debugtest") + .executes(context -> { + + PackConverter packConverter = new PackConverter() + .packName("RAINBOW-TEST") + .output(FabricLoader.getInstance().getGameDir().resolve("pack-convert-out")); + Path tmpDir = FabricLoader.getInstance().getGameDir().resolve("pack-convert-temp"); + + ImageIO.scanForPlugins(); + MinecraftCreativeResourcePack resourcePack = new MinecraftCreativeResourcePack(Minecraft.getInstance().getResourceManager()); + BedrockResourcePack bedrockResourcePack = new BedrockResourcePack(tmpDir); + + LogListener logListener = new DefaultLogListener(); + final Converter.ConversionDataCreationContext conversionDataCreationContext = new Converter.ConversionDataCreationContext( + packConverter, logListener, null, tmpDir, resourcePack, resourcePack + ); + + + List> converters = new ArrayList<>(); + converters.add(new PackManifestConverter()); + converters.add(new LangConverter()); + converters.add(new ModelConverter()); + + int errors = 0; + for (Converter converter : converters) { + ConversionData data = converter.createConversionData(conversionDataCreationContext); + PackConversionContext conversionContext = new PackConversionContext<>(data, packConverter, resourcePack, bedrockResourcePack, logListener); + + List> actionListeners = List.of(); + try { + actionListeners.forEach(actionListener -> actionListener.preConvert((PackConversionContext) conversionContext)); + converter.convert(conversionContext); + actionListeners.forEach(actionListener -> actionListener.postConvert((PackConversionContext) conversionContext)); + } catch (Throwable t) { + logListener.error("Error converting pack!", t); + errors++; + } + } + + try { + bedrockResourcePack.export(); + } catch (IOException e) { + throw new RuntimeException(e); + } + context.getSource().sendFeedback(Component.literal("exporting, " + errors + " errors")); + + return 0; + }) + ); + }); } public static ResourceLocation getModdedLocation(String path) { diff --git a/src/main/java/org/geysermc/rainbow/creative/CachingStreamResourceContainer.java b/src/main/java/org/geysermc/rainbow/creative/CachingStreamResourceContainer.java new file mode 100644 index 0000000..1beb8c5 --- /dev/null +++ b/src/main/java/org/geysermc/rainbow/creative/CachingStreamResourceContainer.java @@ -0,0 +1,42 @@ +package org.geysermc.rainbow.creative; + +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.Reference2ObjectMap; +import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import team.unnamed.creative.part.ResourcePackPart; +import team.unnamed.creative.serialize.minecraft.ResourceCategory; +import team.unnamed.creative.sound.SoundRegistry; +import team.unnamed.creative.texture.Texture; + +@SuppressWarnings("UnstableApiUsage") +public abstract class CachingStreamResourceContainer implements StreamResourceContainer { + private final Reference2ObjectMap, Object2ObjectMap> cache = new Reference2ObjectOpenHashMap<>(); + private final Object2ObjectMap soundRegistryCache = new Object2ObjectOpenHashMap<>(); + private final Object2ObjectMap textureCache = new Object2ObjectOpenHashMap<>(); + + @SuppressWarnings("unchecked") + private T cacheOrDeserialize(ResourceCategory deserializer, Key key) { + Object2ObjectMap deserializerCache = cache.computeIfAbsent(deserializer, cacheKey -> new Object2ObjectOpenHashMap<>()); + return (T) deserializerCache.computeIfAbsent(key, cacheKey -> StreamResourceContainer.super.deserialize(deserializer, key)); + } + + @Override + public @Nullable T deserialize(ResourceCategory deserializer, Key key) { + return cacheOrDeserialize(deserializer, key); + } + + @Override + public @Nullable SoundRegistry soundRegistry(@NotNull String namespace) { + return soundRegistryCache.computeIfAbsent(namespace, cacheNamespace -> StreamResourceContainer.super.soundRegistry(namespace)); + } + + @Override + public @Nullable Texture texture(@NotNull Key key) { + return textureCache.computeIfAbsent(key, cacheKey -> StreamResourceContainer.super.texture(key)); + } +} diff --git a/src/main/java/org/geysermc/rainbow/creative/ImmutableResourceContainer.java b/src/main/java/org/geysermc/rainbow/creative/ImmutableResourceContainer.java new file mode 100644 index 0000000..4859eb4 --- /dev/null +++ b/src/main/java/org/geysermc/rainbow/creative/ImmutableResourceContainer.java @@ -0,0 +1,140 @@ +package org.geysermc.rainbow.creative; + +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import team.unnamed.creative.atlas.Atlas; +import team.unnamed.creative.base.Writable; +import team.unnamed.creative.blockstate.BlockState; +import team.unnamed.creative.equipment.Equipment; +import team.unnamed.creative.font.Font; +import team.unnamed.creative.item.Item; +import team.unnamed.creative.lang.Language; +import team.unnamed.creative.model.Model; +import team.unnamed.creative.overlay.ResourceContainer; +import team.unnamed.creative.resources.MergeStrategy; +import team.unnamed.creative.sound.Sound; +import team.unnamed.creative.sound.SoundRegistry; +import team.unnamed.creative.texture.Texture; + +@SuppressWarnings("NonExtendableApiUsage") +public interface ImmutableResourceContainer extends ResourceContainer { + + private static T thr() { + throw new UnsupportedOperationException("ResourceContainer is immutable"); + } + + @Override + default void atlas(@NotNull Atlas atlas) { + thr(); + } + + @Override + default boolean removeAtlas(@NotNull Key key) { + return thr(); + } + + @Override + default void blockState(@NotNull BlockState state) { + thr(); + } + + @Override + default boolean removeBlockState(@NotNull Key key) { + return thr(); + } + + @Override + default void equipment(@NotNull Equipment equipment) { + thr(); + } + + @Override + default boolean removeEquipment(@NotNull Key key) { + return thr(); + } + + @Override + default void font(@NotNull Font font) { + thr(); + } + + @Override + default boolean removeFont(@NotNull Key key) { + return thr(); + } + + @Override + default void item(@NotNull Item item) { + thr(); + } + + @Override + default boolean removeItem(@NotNull Key key) { + return thr(); + } + + @Override + default void language(@NotNull Language language) { + thr(); + } + + @Override + default boolean removeLanguage(@NotNull Key key) { + return thr(); + } + + @Override + default void model(@NotNull Model model) { + thr(); + } + + @Override + default boolean removeModel(@NotNull Key key) { + return thr(); + } + + @Override + default void soundRegistry(@NotNull SoundRegistry soundRegistry) { + thr(); + } + + @Override + default boolean removeSoundRegistry(@NotNull String namespace) { + return thr(); + } + + @Override + default void sound(@NotNull Sound sound) { + thr(); + } + + @Override + default boolean removeSound(@NotNull Key key) { + return thr(); + } + + @Override + default void texture(@NotNull Texture texture) { + thr(); + } + + @Override + default boolean removeTexture(@NotNull Key key) { + return thr(); + } + + @Override + default void unknownFile(@NotNull String path, @NotNull Writable data) { + thr(); + } + + @Override + default boolean removeUnknownFile(@NotNull String path) { + return thr(); + } + + @Override + default void merge(@NotNull ResourceContainer other, @NotNull MergeStrategy strategy) { + thr(); + } +} diff --git a/src/main/java/org/geysermc/rainbow/creative/ImmutableResourcePack.java b/src/main/java/org/geysermc/rainbow/creative/ImmutableResourcePack.java new file mode 100644 index 0000000..37edc55 --- /dev/null +++ b/src/main/java/org/geysermc/rainbow/creative/ImmutableResourcePack.java @@ -0,0 +1,37 @@ +package org.geysermc.rainbow.creative; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import team.unnamed.creative.ResourcePack; +import team.unnamed.creative.base.Writable; +import team.unnamed.creative.metadata.Metadata; +import team.unnamed.creative.metadata.sodium.SodiumMeta; +import team.unnamed.creative.overlay.Overlay; + +@SuppressWarnings("NonExtendableApiUsage") +public interface ImmutableResourcePack extends ResourcePack, ImmutableResourceContainer { + + private static void thr() { + throw new UnsupportedOperationException("ResourcePack is immutable"); + } + + @Override + default void icon(@Nullable Writable icon) { + thr(); + } + + @Override + default void metadata(@NotNull Metadata metadata) { + thr(); + } + + @Override + default void overlay(@NotNull Overlay overlay) { + thr(); + } + + @Override + default void sodiumMeta(@NotNull SodiumMeta sodiumMeta) { + thr(); + } +} diff --git a/src/main/java/org/geysermc/rainbow/creative/MinecraftCreativeResourcePack.java b/src/main/java/org/geysermc/rainbow/creative/MinecraftCreativeResourcePack.java new file mode 100644 index 0000000..833769a --- /dev/null +++ b/src/main/java/org/geysermc/rainbow/creative/MinecraftCreativeResourcePack.java @@ -0,0 +1,70 @@ +package org.geysermc.rainbow.creative; + +import net.kyori.adventure.key.Key; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.geysermc.rainbow.KeyUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import team.unnamed.creative.base.Writable; +import team.unnamed.creative.metadata.Metadata; +import team.unnamed.creative.overlay.Overlay; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +public class MinecraftCreativeResourcePack extends CachingStreamResourceContainer implements ImmutableResourcePack { + private static final Pattern PATH_SANITIZE_REGEX = Pattern.compile("(^\\w+/)(.*)(\\.\\w+$)"); + + private final ResourceManager resourceManager; + + public MinecraftCreativeResourcePack(ResourceManager resourceManager) { + this.resourceManager = resourceManager; + } + + @Override + public @Nullable InputStream open(Key key) throws IOException { + Optional resource = resourceManager.getResource(KeyUtil.keyToResourceLocation(key)); + if (resource.isPresent()) { + return resource.get().open(); + } + return null; + } + + @Override + public List assets(String category) { + return resourceManager.listResources(category, resource -> true).keySet().stream() + .map(location -> location.withPath(path -> PATH_SANITIZE_REGEX.matcher(path).replaceAll("$2"))) + .map(KeyUtil::resourceLocationToKey) + .toList(); + } + + @Override + public Collection namespaces() { + return resourceManager.getNamespaces(); + } + + @Override + public @Nullable Writable icon() { + return null; + } + + @Override + public @NotNull Metadata metadata() { + return Metadata.empty(); + } + + @Override + public @Nullable Overlay overlay(@NotNull String directory) { + return null; + } + + @Override + public @NotNull Collection overlays() { + return List.of(); + } +} diff --git a/src/main/java/org/geysermc/rainbow/creative/StreamResourceContainer.java b/src/main/java/org/geysermc/rainbow/creative/StreamResourceContainer.java new file mode 100644 index 0000000..f260518 --- /dev/null +++ b/src/main/java/org/geysermc/rainbow/creative/StreamResourceContainer.java @@ -0,0 +1,254 @@ +package org.geysermc.rainbow.creative; + +import com.google.gson.JsonElement; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import team.unnamed.creative.atlas.Atlas; +import team.unnamed.creative.base.Writable; +import team.unnamed.creative.blockstate.BlockState; +import team.unnamed.creative.equipment.Equipment; +import team.unnamed.creative.font.Font; +import team.unnamed.creative.item.Item; +import team.unnamed.creative.lang.Language; +import team.unnamed.creative.metadata.Metadata; +import team.unnamed.creative.model.Model; +import team.unnamed.creative.part.ResourcePackPart; +import team.unnamed.creative.serialize.minecraft.GsonUtil; +import team.unnamed.creative.serialize.minecraft.MinecraftResourcePackStructure; +import team.unnamed.creative.serialize.minecraft.ResourceCategory; +import team.unnamed.creative.serialize.minecraft.atlas.AtlasSerializer; +import team.unnamed.creative.serialize.minecraft.blockstate.BlockStateSerializer; +import team.unnamed.creative.serialize.minecraft.equipment.EquipmentCategory; +import team.unnamed.creative.serialize.minecraft.font.FontSerializer; +import team.unnamed.creative.serialize.minecraft.item.ItemSerializer; +import team.unnamed.creative.serialize.minecraft.language.LanguageSerializer; +import team.unnamed.creative.serialize.minecraft.metadata.MetadataSerializer; +import team.unnamed.creative.serialize.minecraft.model.ModelSerializer; +import team.unnamed.creative.serialize.minecraft.sound.SoundRegistrySerializer; +import team.unnamed.creative.serialize.minecraft.sound.SoundSerializer; +import team.unnamed.creative.sound.Sound; +import team.unnamed.creative.sound.SoundRegistry; +import team.unnamed.creative.texture.Texture; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +@SuppressWarnings({"UnstableApiUsage", "PatternValidation"}) +public interface StreamResourceContainer extends ImmutableResourceContainer { + + @Nullable + InputStream open(Key key) throws IOException; + + List assets(String category); + + Collection namespaces(); + + String METADATA_EXTENSION = MinecraftResourcePackStructure.METADATA_EXTENSION; + String TEXTURE_EXTENSION = MinecraftResourcePackStructure.TEXTURE_EXTENSION; + String TEXTURE_CATEGORY = MinecraftResourcePackStructure.TEXTURES_FOLDER; + String SOUNDS_FILE = MinecraftResourcePackStructure.SOUNDS_FILE; + + private static Key addExtensionAndCategory(Key key, String category, String extension) { + return Key.key(key.namespace(), category + "/" + key.value() + extension); + } + + private static JsonElement parseJson(InputStream input) throws IOException { + try (Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + return GsonUtil.parseReader(reader); + } + } + + @Nullable + private static Writable writable(Key key, String category, String extension, ResourceOpener opener) throws IOException { + Key withExtension = addExtensionAndCategory(key, category, extension); + try (InputStream input = opener.open(withExtension)) { + if (input != null) { + return Writable.inputStream(() -> opener.open(withExtension)); + } + } + return null; + } + + private static List collect(ResourceCategory category, Function deserializer, + Function> assetLookup) { + return assetLookup.apply(category.folder(-1)).stream() + .map(deserializer) + .filter(Objects::nonNull) + .toList(); + } + + @Nullable + default T deserialize(ResourceCategory category, Key key) { + Key withExtensionAndCategory = addExtensionAndCategory(key, category.folder(-1), category.extension(-1)); + try (InputStream input = open(withExtensionAndCategory)) { + if (input != null) { + return category.deserializer().deserialize(input, key); + } + } catch (IOException exception) { + throw new RuntimeException(exception); + } + return null; + } + + @Override + default @Nullable Atlas atlas(@NotNull Key key) { + return deserialize(AtlasSerializer.CATEGORY, key); + } + + @Override + default @NotNull Collection atlases() { + return collect(AtlasSerializer.CATEGORY, this::atlas, this::assets); + } + + @Override + default @Nullable BlockState blockState(@NotNull Key key) { + return deserialize(BlockStateSerializer.CATEGORY, key); + } + + @Override + @NotNull + default Collection blockStates() { + return collect(BlockStateSerializer.CATEGORY, this::blockState, this::assets); + } + + @Override + default @Nullable Equipment equipment(@NotNull Key key) { + return deserialize(EquipmentCategory.INSTANCE, key); + } + + @Override + @NotNull + default Collection equipment() { + return collect(EquipmentCategory.INSTANCE, this::equipment, this::assets); + } + + @Override + default @Nullable Font font(@NotNull Key key) { + return deserialize(FontSerializer.CATEGORY, key); + } + + @Override + @NotNull + default Collection fonts() { + return collect(FontSerializer.CATEGORY, this::font, this::assets); + } + + @Override + default @Nullable Item item(@NotNull Key key) { + return deserialize(ItemSerializer.CATEGORY, key); + } + + @Override + @NotNull + default Collection items() { + return collect(ItemSerializer.CATEGORY, this::item, this::assets); + } + + @Override + default @Nullable Language language(@NotNull Key key) { + return deserialize(LanguageSerializer.CATEGORY, key); + } + + @Override + @NotNull + default Collection languages() { + return collect(LanguageSerializer.CATEGORY, this::language, this::assets); + } + + @Override + default @Nullable Model model(@NotNull Key key) { + return deserialize(ModelSerializer.CATEGORY, key); + } + + @Override + @NotNull + default Collection models() { + return collect(ModelSerializer.CATEGORY, this::model, this::assets); + } + + @Override + default @Nullable SoundRegistry soundRegistry(@NotNull String namespace) { + try (InputStream input = open(Key.key(namespace, SOUNDS_FILE))) { + if (input != null) { + return SoundRegistrySerializer.INSTANCE.readFromTree(parseJson(input), namespace); + } + } catch (IOException exception) { + throw new RuntimeException(exception); + } + return null; + } + + @Override + @NotNull + default Collection soundRegistries() { + return namespaces().stream() + .map(this::soundRegistry) + .filter(Objects::nonNull) + .toList(); + } + + @Override + default @Nullable Sound sound(@NotNull Key key) { + return deserialize(SoundSerializer.CATEGORY, key); + } + + @Override + @NotNull + default Collection sounds() { + return collect(SoundSerializer.CATEGORY, this::sound, this::assets); + } + + @Override + default @Nullable Texture texture(@NotNull Key key) { + try { + Writable texture = writable(key, TEXTURE_CATEGORY, TEXTURE_EXTENSION, this::open); + if (texture != null) { + Metadata metadata = Metadata.empty(); + try (InputStream metadataStream = open(addExtensionAndCategory(key, TEXTURE_CATEGORY, METADATA_EXTENSION))) { + if (metadataStream != null) { + metadata = MetadataSerializer.INSTANCE.readFromTree(parseJson(metadataStream)); + } + } + return Texture.texture(key, texture, metadata); + } + } catch (IOException exception) { + throw new RuntimeException(exception); + } + return null; + } + + @Override + @NotNull + default Collection textures() { + return assets(TEXTURE_CATEGORY).stream() + .map(this::texture) + .filter(Objects::nonNull) + .toList(); + } + + @Override + default @Nullable Writable unknownFile(@NotNull String path) { + throw new UnsupportedOperationException("Unsupported by StreamResourceContainer"); + } + + @Override + default @NotNull Map unknownFiles() { + return Map.of(); + } + + @FunctionalInterface + interface ResourceOpener { + + InputStream open(Key key) throws IOException; + } +}