diff --git a/src/generated_test/resources/assets/minecraft/particles/cloud.json b/src/generated_test/resources/assets/minecraft/particles/cloud.json new file mode 100644 index 0000000000..271261099b --- /dev/null +++ b/src/generated_test/resources/assets/minecraft/particles/cloud.json @@ -0,0 +1,12 @@ +{ + "textures": [ + "minecraft:generic_7", + "minecraft:generic_6", + "minecraft:generic_5", + "minecraft:generic_4", + "minecraft:generic_3", + "minecraft:generic_2", + "minecraft:generic_1", + "minecraft:generic_0" + ] +} \ No newline at end of file diff --git a/src/generated_test/resources/assets/minecraft/particles/dripping_lava.json b/src/generated_test/resources/assets/minecraft/particles/dripping_lava.json new file mode 100644 index 0000000000..987e9f30aa --- /dev/null +++ b/src/generated_test/resources/assets/minecraft/particles/dripping_lava.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "minecraft:drip_hang" + ] +} \ No newline at end of file diff --git a/src/generated_test/resources/assets/minecraft/particles/enchant.json b/src/generated_test/resources/assets/minecraft/particles/enchant.json new file mode 100644 index 0000000000..d2175f03fc --- /dev/null +++ b/src/generated_test/resources/assets/minecraft/particles/enchant.json @@ -0,0 +1,30 @@ +{ + "textures": [ + "minecraft:sga_a", + "minecraft:sga_b", + "minecraft:sga_c", + "minecraft:sga_d", + "minecraft:sga_e", + "minecraft:sga_f", + "minecraft:sga_g", + "minecraft:sga_h", + "minecraft:sga_i", + "minecraft:sga_j", + "minecraft:sga_k", + "minecraft:sga_l", + "minecraft:sga_m", + "minecraft:sga_n", + "minecraft:sga_o", + "minecraft:sga_p", + "minecraft:sga_q", + "minecraft:sga_r", + "minecraft:sga_s", + "minecraft:sga_t", + "minecraft:sga_u", + "minecraft:sga_v", + "minecraft:sga_w", + "minecraft:sga_x", + "minecraft:sga_y", + "minecraft:sga_z" + ] +} \ No newline at end of file diff --git a/src/generated_test/resources/assets/minecraft/particles/fishing.json b/src/generated_test/resources/assets/minecraft/particles/fishing.json new file mode 100644 index 0000000000..49f427bca6 --- /dev/null +++ b/src/generated_test/resources/assets/minecraft/particles/fishing.json @@ -0,0 +1,8 @@ +{ + "textures": [ + "minecraft:splash_0", + "minecraft:splash_1", + "minecraft:splash_2", + "minecraft:splash_3" + ] +} \ No newline at end of file diff --git a/src/main/java/net/minecraftforge/common/data/ParticleDescriptionProvider.java b/src/main/java/net/minecraftforge/common/data/ParticleDescriptionProvider.java new file mode 100644 index 0000000000..c54e267688 --- /dev/null +++ b/src/main/java/net/minecraftforge/common/data/ParticleDescriptionProvider.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) Forge Development LLC and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.minecraftforge.common.data; + +import com.google.common.base.Preconditions; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.minecraft.Util; +import net.minecraft.client.particle.ParticleEngine; +import net.minecraft.client.particle.ParticleProvider; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.data.CachedOutput; +import net.minecraft.data.DataProvider; +import net.minecraft.data.PackOutput; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +/** + * A data provider for {@link net.minecraft.client.particle.ParticleDescription}s. + * + *

To use this provider, extend this class and implement {@link #addDescriptions()}. + * Then, register an instance using {@link net.minecraft.data.DataGenerator#addProvider(boolean, Factory)} + * via the {@link net.minecraftforge.data.event.GatherDataEvent} on the {@linkplain FMLJavaModLoadingContext#getModEventBus() mod event bus}. + * + *

A description can be added to a {@link ParticleType} which uses a {@linkplain #sprite(ParticleType, ResourceLocation) sprite} + * or {@linkplain #spriteSet(ParticleType, Iterable) sprite set}. + * + *

{@code
+ * @Override
+ * protected void addDescriptions() {
+ *     // Single sprite
+ *     this.sprite(ParticleTypes.DRIPPING_LAVA, new ResourceLocation("drip_hang"));
+ *
+ *     // Multiple sprites
+ *     this.spriteSet(ParticleTypes.CLOUD, new ResourceLocation("generic"), 8, true);
+ * }
+ * }
+ * + *

A particle description holds a list of textures used when rendering the + * particle to the screen. All registered particle descriptions are stitched + * together into a texture atlas called {@link net.minecraft.client.renderer.texture.TextureAtlas#LOCATION_PARTICLES}. + * A {@link ParticleType} whose particle uses the texture atlas, typically via + * the {@link net.minecraft.client.particle.ParticleRenderType}, can then reference + * the necessary texture during rendering. + * + *

Particles with a particle description must have their particle providers + * attached to a {@link ParticleType} as a {@linkplain net.minecraftforge.client.event.RegisterParticleProvidersEvent#registerSprite(ParticleType, ParticleProvider.Sprite) sprite} + * or {@linkplain net.minecraftforge.client.event.RegisterParticleProvidersEvent#registerSpriteSet(ParticleType, ParticleEngine.SpriteParticleRegistration) sprite set} + * consumer. + * + * @see DataProvider + * @see net.minecraft.client.particle.ParticleDescription + */ +public abstract class ParticleDescriptionProvider implements DataProvider { + + private final PackOutput.PathProvider particlesPath; + @VisibleForTesting + protected final ExistingFileHelper fileHelper; + @VisibleForTesting + protected final Map> descriptions; + + /** + * Creates an instance of the data provider. + * + * @param output the expected root directory the data generator outputs to + * @param fileHelper the helper used to validate a texture's existence + */ + protected ParticleDescriptionProvider(PackOutput output, ExistingFileHelper fileHelper) { + this.particlesPath = output.createPathProvider(PackOutput.Target.RESOURCE_PACK, "particles"); + this.fileHelper = fileHelper; + this.descriptions = new HashMap<>(); + } + + /** + * Registers the particle descriptions generated by {@link #sprite(ParticleType, ResourceLocation)} + * or one of the {@link #spriteSet(ParticleType, Iterable) sprite set methods}. + */ + protected abstract void addDescriptions(); + + /** + * Creates a new particle description that contains a single texture for the + * associated {@link ParticleType}. + * + *

Particle types with this description should be attached to a particle provider + * via {@link net.minecraftforge.client.event.RegisterParticleProvidersEvent#registerSprite(ParticleType, ParticleProvider.Sprite)}. + * + * @param type the particle type the textures are applied + * for + * @param texture the texture to render for the particle type + * @throws NullPointerException if the particle type is not registered + * @throws IllegalArgumentException if a texture does not have an associated PNG + * file, or the particle type has already been + * provided + */ + protected void sprite(ParticleType type, ResourceLocation texture) { + this.spriteSet(type, texture); + } + + /** + * Creates a new particle description that contains multiple textures for the + * associated {@link ParticleType}. The textures are generated from a common + * name and appended with a number representing the state of the animation. + * + *

{@code
+     * minecraft:generic_0
+     * minecraft:generic_1
+     * minecraft:generic_2
+     * // ...
+     * }
+ * + *

Particle types with this description should be attached to a particle provider + * via {@link net.minecraftforge.client.event.RegisterParticleProvidersEvent#registerSpriteSet(ParticleType, ParticleEngine.SpriteParticleRegistration)}. + * + * @param type the particle type the textures are applied + * for + * @param baseName the common name of all the textures + * @param numOfTextures the number of textures within the set + * @param reverse when {@code true}, the textures will be + * listed in descending order + * @throws NullPointerException if the particle type is not registered + * @throws IllegalArgumentException if a texture does not have an associated PNG + * file, or the particle type has already been + * provided + */ + protected void spriteSet(ParticleType type, ResourceLocation baseName, int numOfTextures, boolean reverse) { + Preconditions.checkArgument(numOfTextures > 0, "The number of textures to generate must be positive"); + this.spriteSet(type, () -> new Iterator<>() { + + private int counter = 0; + + @Override + public boolean hasNext() { + return this.counter < numOfTextures; + } + + @Override + public ResourceLocation next() { + var texture = baseName.withSuffix("_" + (reverse ? numOfTextures - this.counter - 1 : this.counter)); + this.counter++; + return texture; + } + }); + } + + /** + * Creates a new particle description that contains multiple textures for the + * associated {@link ParticleType}. The textures are passed as varargs with + * at least one texture present. + * + *

Particle types with this description should be attached to a particle provider + * via {@link net.minecraftforge.client.event.RegisterParticleProvidersEvent#registerSpriteSet(ParticleType, ParticleEngine.SpriteParticleRegistration)}. + * + * @param type the particle type the textures are applied + * for + * @param texture the first texture in the description + * @param textures a list of subsequent textures to render for + * the particle type + * @throws NullPointerException if the particle type is not registered + * @throws IllegalArgumentException if a texture does not have an associated PNG + * file, or the particle type has already been + * provided + */ + protected void spriteSet(ParticleType type, ResourceLocation texture, ResourceLocation... textures) { + this.spriteSet(type, Stream.concat(Stream.of(texture), Arrays.stream(textures))::iterator); + } + + /** + * Creates a new particle description that contains multiple textures for the + * associated {@link ParticleType}. The textures are passed as an iterable. + * + *

Particle types with this description should be attached to a particle provider + * via {@link net.minecraftforge.client.event.RegisterParticleProvidersEvent#registerSpriteSet(ParticleType, ParticleEngine.SpriteParticleRegistration)}. + * + * @param type the particle type the textures are applied + * for + * @param textures a list of textures to render for the + * particle type + * @throws NullPointerException if the particle type is not registered + * @throws IllegalArgumentException if there are no textures provided, a texture + * does not have an associated PNG file, or + * the particle type has already been provided + */ + protected void spriteSet(ParticleType type, Iterable textures) { + // Make sure particle type is registered + var particle = Preconditions.checkNotNull(ForgeRegistries.PARTICLE_TYPES.getKey(type), "The particle type is not registered"); + + // Validate textures + List desc = new ArrayList<>(); + for (var texture : textures) { + Preconditions.checkArgument(this.fileHelper.exists(texture, PackType.CLIENT_RESOURCES, ".png", "textures/particle"), + "Texture '%s' does not exist in any known resource pack", texture); + desc.add(texture.toString()); + } + Preconditions.checkArgument(desc.size() > 0, "The particle type '%s' must have one texture", particle); + + // Insert into map + if (this.descriptions.putIfAbsent(particle, desc) != null) { + throw new IllegalArgumentException(String.format("The particle type '%s' already has a description associated with it", particle)); + } + } + + @Override + public CompletableFuture run(CachedOutput cache) { + this.addDescriptions(); + + return CompletableFuture.allOf( + this.descriptions.entrySet().stream().map(entry -> { + // Map entries to the description format + var textures = new JsonArray(); + entry.getValue().forEach(textures::add); + return DataProvider.saveStable(cache, + Util.make(new JsonObject(), obj -> obj.add("textures", textures)), + this.particlesPath.json(entry.getKey()) + ); + }).toArray(CompletableFuture[]::new) + ); + } + + @Override + public String getName() { + return "Particle Descriptions"; + } +} diff --git a/src/test/java/net/minecraftforge/debug/DataGeneratorTest.java b/src/test/java/net/minecraftforge/debug/DataGeneratorTest.java index c2c766070e..a7bb9dbc7a 100644 --- a/src/test/java/net/minecraftforge/debug/DataGeneratorTest.java +++ b/src/test/java/net/minecraftforge/debug/DataGeneratorTest.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -34,6 +35,7 @@ import net.minecraft.DetectedVersion; import net.minecraft.Util; import net.minecraft.advancements.critereon.InventoryChangeTrigger; import net.minecraft.core.*; +import net.minecraft.core.particles.ParticleTypes; import net.minecraft.core.registries.Registries; import net.minecraft.data.CachedOutput; import net.minecraft.data.PackOutput; @@ -169,6 +171,7 @@ public class DataGeneratorTest gen.addProvider(event.includeClient(), itemModels); gen.addProvider(event.includeClient(), new BlockStates(packOutput, itemModels.existingFileHelper)); gen.addProvider(event.includeClient(), new SoundDefinitions(packOutput, event.getExistingFileHelper())); + gen.addProvider(event.includeClient(), new ParticleDescriptions(packOutput, event.getExistingFileHelper())); gen.addProvider(event.includeServer(), new Recipes(packOutput)); gen.addProvider(event.includeServer(), new Tags(packOutput, lookupProvider, event.getExistingFileHelper())); @@ -1125,6 +1128,80 @@ public class DataGeneratorTest } } + private static class ParticleDescriptions extends ParticleDescriptionProvider { + + public ParticleDescriptions(PackOutput output, ExistingFileHelper fileHelper) { + super(output, fileHelper); + } + + @Override + protected void addDescriptions() { + this.sprite(ParticleTypes.DRIPPING_LAVA, new ResourceLocation("drip_hang")); + + this.spriteSet(ParticleTypes.CLOUD, new ResourceLocation("generic"), 8, true); + + this.spriteSet(ParticleTypes.FISHING, + new ResourceLocation("splash_0"), + new ResourceLocation("splash_1"), + new ResourceLocation("splash_2"), + new ResourceLocation("splash_3") + ); + + this.spriteSet(ParticleTypes.ENCHANT, () -> new Iterator<>() { + + private final ResourceLocation base = new ResourceLocation("sga"); + private char suffix = 'a'; + + @Override + public boolean hasNext() { + return this.suffix <= 'z'; + } + + @Override + public ResourceLocation next() { + return this.base.withSuffix("_" + this.suffix++); + } + }); + } + + @Override + public CompletableFuture run(CachedOutput cache) { + return super.run(cache).thenRun(this::validateResults); + } + + private void validateResults() { + var errors = Stream.of(ParticleTypes.DRIPPING_LAVA, ParticleTypes.CLOUD, ParticleTypes.FISHING, ParticleTypes.ENCHANT) + .map(ForgeRegistries.PARTICLE_TYPES::getKey).map(particle -> { + try (var resource = this.fileHelper.getResource(particle, PackType.CLIENT_RESOURCES, ".json", "particles").openAsReader()) { + var existingTextures = GSON.fromJson(resource, JsonObject.class).get("textures").getAsJsonArray(); + var generatedTextures = this.descriptions.get(particle); + + // Check texture size + if (existingTextures.size() != generatedTextures.size()) { + LOGGER.error("%s had a different number of sprites, expected %s, actual %s", particle, existingTextures.size(), generatedTextures.size()); + return particle; + } + + boolean error = false; + for (int i = 0; i < generatedTextures.size(); ++i) { + if (!existingTextures.get(i).getAsString().equals(generatedTextures.get(i))) { + LOGGER.error("%s index %s: expected %s, actual %s", particle, i, existingTextures.get(i).getAsString(), generatedTextures.get(i)); + error = true; + } + } + + return error ? particle : null; + } catch (IOException e) { + throw new RuntimeException(e); + } + }).filter(Objects::nonNull).toList(); + + if (!errors.isEmpty()) { + throw new AssertionError(String.format("Validation errors found in %s; see above for details", errors.stream().reduce("", (str, rl) -> str + ", " + rl, (str1, str2) -> str1 + ", " + str2))); + } + } + } + private static > List testModelResults(Map models, ExistingFileHelper existingFileHelper, Set toIgnore) { List ret = new ArrayList<>(); models.forEach((loc, model) -> {