Minecraft Modding: Custom Triggers (a.k.a. Criteria)

Background


As explained in my overview on advancements, sometimes you want to create a trigger that isn't covered by the built-in ones. This tutorial explains how to do this.

Unfortunately Forge has not yet provided a hook to the CriteriaTriggers.REGISTRY or register() methods which are both private. So in order to register custom triggers (called "criterion") you will need to use Java reflection to access the registry. The only trick is that your development environment is deobfuscated meaning the method name in the actual Minecraft executable is not the same as it is in your IDE. So Forge has provided the ReflectionHelper class to allow you to invoke the same method even if it has a different obfuscated name.

The overall code for criteria is a bit complicated to follow if you're not strong in Java. For example, the vanilla criterion classes use a factory method to retrieve instances rather than calling constructors directly, and furthermore it needs to manage a list of "listeners" since each player has its own advancement manager that may need updating any time a trigger occurs. So it can get a bit confusing. However I've come up with a flexible, simple scheme for creating custom criteria which your are welcome to copy even if you don't fully understand it.

Key Point: Each criterion has a test() method that gets checked whenever the criteria class' trigger() method is called (via the listener).

An Approach For Generic Triggering System


I came up with a flexible, simple system that avoids having to do either much coding or much JSON work. Yay!

Step 1: Copy My CustomTrigger class


Basically I copied a typical criteria class from vanilla and I changed it so that it had constructors that could take the trigger name (either as a String or a ResourceLocation).

Key Point: With this constructor approach you only need one class no matter how many triggers you want to register. They are differentiated by the trigger name.

Simply copy this class directly. You don't even need to understand it, but please try:

import java.util.ArrayList;
import java.util.Map;
import java.util.Set;

import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonObject;

import net.minecraft.advancements.ICriterionTrigger;
import net.minecraft.advancements.PlayerAdvancements;
import net.minecraft.advancements.critereon.AbstractCriterionInstance;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.util.ResourceLocation;

public class CustomTrigger implements ICriterionTrigger
{
    private final ResourceLocation RL;
    private final Map listeners = Maps.newHashMap();

    public CustomTrigger(String parString)
    {
        super();
        RL = new ResourceLocation(parString);
    }
    
    public CustomTrigger(ResourceLocation parRL)
    {
        super();
        RL = parRL;
    }
    
    /* (non-Javadoc)
     * @see net.minecraft.advancements.ICriterionTrigger#getId()
     */
    @Override
    public ResourceLocation getId()
    {
        return RL;
    }

    @Override
    public void addListener(PlayerAdvancements playerAdvancementsIn, ICriterionTrigger.Listener listener)
    {
        CustomTrigger.Listeners myCustomTrigger$listeners = listeners.get(playerAdvancementsIn);

        if (myCustomTrigger$listeners == null)
        {
            myCustomTrigger$listeners = new CustomTrigger.Listeners(playerAdvancementsIn);
            listeners.put(playerAdvancementsIn, myCustomTrigger$listeners);
        }

        myCustomTrigger$listeners.add(listener);
    }

    @Override
    public void removeListener(PlayerAdvancements playerAdvancementsIn, ICriterionTrigger.Listener listener)
    {
        CustomTrigger.Listeners tameanimaltrigger$listeners = listeners.get(playerAdvancementsIn);

        if (tameanimaltrigger$listeners != null)
        {
            tameanimaltrigger$listeners.remove(listener);

            if (tameanimaltrigger$listeners.isEmpty())
            {
                listeners.remove(playerAdvancementsIn);
            }
        }
    }

    @Override
    public void removeAllListeners(PlayerAdvancements playerAdvancementsIn)
    {
        listeners.remove(playerAdvancementsIn);
    }

    /**
     * Deserialize a ICriterionInstance of this trigger from the data in the JSON.
     *
     * @param json the json
     * @param context the context
     * @return the tame bird trigger. instance
     */
    @Override
    public CustomTrigger.Instance deserializeInstance(JsonObject json, JsonDeserializationContext context)
    {
        return new CustomTrigger.Instance(getId());
    }

    /**
     * Trigger.
     *
     * @param parPlayer the player
     */
    public void trigger(EntityPlayerMP parPlayer)
    {
        CustomTrigger.Listeners tameanimaltrigger$listeners = listeners.get(parPlayer.getAdvancements());

        if (tameanimaltrigger$listeners != null)
        {
            tameanimaltrigger$listeners.trigger(parPlayer);
        }
    }

    public static class Instance extends AbstractCriterionInstance
    {
        
        /**
         * Instantiates a new instance.
         */
        public Instance(ResourceLocation parRL)
        {
            super(parRL);
        }

        /**
         * Test.
         *
         * @return true, if successful
         */
        public boolean test()
        {
            return true;
        }
    }

    static class Listeners
    {
        private final PlayerAdvancements playerAdvancements;
        private final Set> listeners = Sets.newHashSet();

        /**
         * Instantiates a new listeners.
         *
         * @param playerAdvancementsIn the player advancements in
         */
        public Listeners(PlayerAdvancements playerAdvancementsIn)
        {
            playerAdvancements = playerAdvancementsIn;
        }

        /**
         * Checks if is empty.
         *
         * @return true, if is empty
         */
        public boolean isEmpty()
        {
            return listeners.isEmpty();
        }

        /**
         * Adds the listener.
         *
         * @param listener the listener
         */
        public void add(ICriterionTrigger.Listener listener)
        {
            listeners.add(listener);
        }

        /**
         * Removes the listener.
         *
         * @param listener the listener
         */
        public void remove(ICriterionTrigger.Listener listener)
        {
            listeners.remove(listener);
        }

        /**
         * Trigger.
         *
         * @param player the player
         */
        public void trigger(EntityPlayerMP player)
        {
            ArrayList> list = null;

            for (ICriterionTrigger.Listener listener : listeners)
            {
                if (listener.getCriterionInstance().test())
                {
                    if (list == null)
                    {
                        list = Lists.newArrayList();
                    }

                    list.add(listener);
                }
            }

            if (list != null)
            {
                for (ICriterionTrigger.Listener listener1 : list)
                {
                    listener1.grantCriterion(playerAdvancements);
                }
            }
        }
    }
}

Note: The code above actually contains several classes: the CustomTrigger class, as well as Instance, and Listeners.

The Listeners class contains the trigger() method that is called across all the listeners (essentially the different players) when the JSON conditions are met. In turn this calls the Instance#test() method.

Key Point: The test() method in my case always returns true because I prefer to test the conditions in outside code before the trigger is even called -- by the time I call the trigger I know I want it to complete. However it is a valid alternative to implement more condition checking within the test() method.

Step 2. Create A Class To Hold Your Trigger Instances


You'll need to be able to reference your trigger instances when you want to trigger them in your code, so I suggest creating a class called ModTriggers that contains something like this:

public class ModTriggers

{
    public static final CustomTrigger PLACE_CLOUD_SAPLING = new CustomTrigger("grow_cloud_sapling");

    /*
     * This array just makes it convenient to register all the criteria.
     */
    public static final CustomTrigger[] TRIGGER_ARRAY = new CustomTrigger[] {
            PLACE_CLOUD_SAPLING
            };
}

You would of course change the name according to your needs. You can also make as many as you need. The array is used to simplify the registration code, otherwise you'd have to modify that every time you added a trigger to this class.

Warning: The string you use in the CustomTrigger constructor must match what you put in your JSON file.

Step 3. Register Your Trigger(s) In Your Proxy


In the "init" handling method of your common proxy, you need to have code something like as follows:
        Method method;

        method = ReflectionHelper.findMethod(CriteriaTriggers.class, "register", "func_192118_a", ICriterionTrigger.class);

        method.setAccessible(true);

        for (int i=0; i < ModTriggers.TRIGGER_ARRAY.length; i++)
        {
            try
            {
                method.invoke(null, ModTriggers.TRIGGER_ARRAY[i]);
            }
            catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e)
            {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        } 

Note that the code may look a bit weird to you because it is using Java reflection (via the ReflectionHelper class) and also is associated with various error "throws" which need to be handled. If you are comfortable with error handling you can change how it is done if you wish (making the whole method a throws, or combining the catches). It is also good practice to put more descriptive console output into the catch.

Step 4. Make The (Simple) JSON Advancement Assets Files


In your project assets create a folder called "advancements" (exactly like that all lower case) and in that folder create a JSON file for each custom trigger with the file name exactly the same as the trigger name. In my example, my PLACE_CLOUD_SAPLING trigger has a name "place_cloud_sapling" so my file is place_cloud_sapling.json.

In each JSON file you should have something like this:
{
    "display": {
        "icon": {
            "item": "minecraft:sapling"
        },
        "title": {
            "translate": "advancements.place_cloud_sapling.title"
        },
        "description": {
            "translate": "advancements.place_cloud_sapling.description"
        }
    },

    "parent": "minecraft:husbandry/root",
    "criteria": {
        "place_cloud_sapling": {
            "trigger": "place_cloud_sapling"
        }
    }
}

Make sure you change things according to your needs. It should be self explanatory but basically you're setting the parent advancement, the icon to display, the lang file keys for the title and description of the advancement, then associating it to the name of the registered trigger.

In this case I'm making the parent to be the husbandry advancement tree, but you could make your trigger under some other advancement, or even be its own root. Look at the official Advancement documentation to understand the types of modification you might want to make.

Warning: You must make sure that all the names match. Your trigger should match the file name, any Minecraft resources you reference need the "minecraft:" indicated. If you make custom parents and put them in different folders, make sure you reference the file path properly.

Step 4. Update Your .lang File(s) For Translation Of The Title And Description


In the previous step, the JSON defines the title and description with .lang file keys. So then you need to update your .lang file with what you want those keys to translate to in the various languages you want to support. For example, in my en_us.lang file I added the following lines:

advancements.place_cloud_sapling.title=Plant A Cloud Sapling
advancements.place_cloud_sapling.description=Plant a sapling from a cloud tree (found in the cloud dimension).

Of course you should change it to match your needs.

Step 5. Trigger Your Criteria Wherever Makes Sense In Your Code

The cool thing is now you can trigger the advancement any time you wish in your code. Just call the instance from your ModTriggers class and run the trigger() method from it. For example, at the point in my code where a bird entity is tamed I simply put:

     ModTriggers.PLACE_CLOUD_SAPLING.trigger((EntityPlayerMP)thePlayer);

Of course you should change the PLACE_CLOUD_SAPLING to the field name for your instance, and you should change thePlayer to whatever field you have that represents the player you want to give the advancement to (usually the current player at the point the code is running, but technically you can give it to any player on the server).

Key Point: You do not need a complex JSON file because instead you are already firing the advancement when the right conditions occur in your code.

Testing Your Code


So, if you did all the above correctly, you should be able to see the advancement trigger when you call it. For example, in my ExampleMod code I made my trigger for when you place a custom sapling ("cloud sapling"). So to test it I used creative mode and placed a sapling and saw that the advancement worked.

Conclusion


I think this approach is pretty slick because after you copy the CustomTrigger class, there isn't a lot more coding or JSON authoring required in order to make complex custom triggers.

Hope you find this useful! As always I welcome comments and corrections. Happy modding!

2 comments:

  1. Hey there!
    When copying your CustomTrigger Class, I encountered multiple errors, such as for example: "Type mismatch: cannot convert from Object to CustomTrigger.Listeners", on lines 49, 66 and so on, I also got a lot of warnings because most of the Listeners you use are raw, but should be parameterized.
    Also I encountered an error on line 194: "The method test() is undefined for the type ICriterionInstance"
    I have a general good understanding of Java, but I'm not sure, casting everything is the right way to solve this, also, I can't even solve the errors on 192 and 207 with casting.
    I'm a little confused right now and hope you could help me. :)

    Thanks!

    ReplyDelete
    Replies
    1. Thanks again for your feedback. Yeah, although it would actually work with all the raw types, it is much better practice to put in the types, which I have now corrected. Sorry for the trouble.

      Delete