Data Driving the Butterflies

Finally! Butterfly data can now be loaded or modified via a JSON file. Now if we decide to change how butterflies work we don’t need to change any code. Even a data pack could be used to change butterfly behaviour.

Today’s Lucky 10,000


When I started doing this I learned something new the hard way.

Since I needed this data to be available on both the server and the client, it obviously needs to be under the \data folder, not the \assets folder. Assets are things like textures, audio, localisation strings, visual and audio things. Only the client needs to know how to render the game and play sounds, so assets aren’t loaded by the server.

Data is the stuff needed to run a game. It has things like recipes, spawn rules, achievements, and so on. These are things the server needs to know as well, and is everything under the \data folder. Minecraft will load these resources whether or not it is running a server or a client.

So, I created some JSON files, and wrote a bit of test code that would load my new data after Minecraft initialises. I ran the game in debug mode and…

It didn’t work. new JSON files were not loaded. I spent over a day debugging and scratching my head, trying to figure out why it didn’t work. Assets loaded fine, but nothing under the data folder. After a lot of Googling, rewriting, debugging, and sticking break points everywhere, I finally figured out the problem. There was something fundamental I didn’t know about how Minecraft loads resources.

Everything under the \data folder only gets loaded when a world is started.

Well, we all learn something new every day. Armed with this new knowledge, I went on to write a function that worked, and I was then able to develop this feature properly.

Data Driving


After figuring out how Minecraft loads data, the first thing to do was to come up with a JSON format for butterfly data. This is probably the easiest part of the whole implementation. As an example here is the JSON data for the admiral butterfly, but you can see all the files in my GitHub repo.

{
  "index": 0,
  "entityId": "admiral",
  "size": "medium",
  "speed": "moderate",
  "rarity": "common",
  "habitat": "forests",
  "lifespan": {
    "caterpillar": "short",
    "chrysalis": "medium",
    "butterfly": "medium"
  }
}

After creating one for each butterfly, the next stage is to load the data in. We do this by listening for LevelEvent.Load, which is fired when a level (or Minecraft world) has been loaded. At this point the \data folder is loaded into memory so we can access it and read the data.

/**
 * Listens for events on loading a level.
 */
@Mod.EventBusSubscriber(modid = ButterfliesMod.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public class LevelEventListener {


    // GSON build that can parse Butterfly entries.
    private static final Gson GSON = (new GsonBuilder()).registerTypeAdapter(ButterflyData.class, new ButterflyData.Serializer()).create();

    // Logger for reporting errors.
    private static final Logger LOGGER = LogUtils.getLogger();

    /**
     * Load butterfly mod-specific data when a level loads.
     * @param event The level load event.
     */
    @SubscribeEvent
    public static void onLevelLoad(LevelEvent.Load event) {
        LOGGER.debug("LevelEvent.Load");

        // Get the resource manager.
        ResourceManager resourceManager = null;
        if (event.getLevel().isClientSide()) {
            resourceManager = Minecraft.getInstance().getResourceManager();
        } else {
            MinecraftServer server = event.getLevel().getServer();
            if (server != null) {
                resourceManager = server.getResourceManager();
            }
        }

        if (resourceManager == null) {
            LOGGER.error("Failed to get Resource Manager");
        } else {
            // Get the butterfly JSON files
            Map<ResourceLocation, Resource> resourceMap =
                    resourceManager.listResources("butterflies", (x) -> x.getPath().endsWith(".json"));

            // Parse each one and generate the data.
            for (ResourceLocation location : resourceMap.keySet()) {
                try {
                    Resource resource = resourceMap.get(location);
                    BufferedReader reader = resource.openAsReader();
                    ButterflyData butterflyData = GSON.fromJson(reader, ButterflyData.class);
                    ButterflyData.addButterfly(butterflyData);
                } catch (IOException e) {
                    LOGGER.error("Failed to load butterfly data: [" + location.toString() + "]", e);
                }
            }
        }
    }
}

We start by getting the resource manager. This is in a slightly different place for servers and clients, so we make sure to get it from the correct place. We also log an error if we can’t find it, which should help us if anything goes wrong.

Next, we use listResources() to get all the files under the \data\butterflies folder, which is where we put all our JSON files. After this we can just use Gson to parse the resources into some ButterflyData objects and add them to our global data.

In order to parse the butterfly data, we implemented a serializer to help us out. You will notice in the code snippet above that when we create a Gson object, we register this serializer with it.

    // GSON build that can parse Butterfly entries.
    private static final Gson GSON = (new GsonBuilder()).registerTypeAdapter(ButterflyData.class, new ButterflyData.Serializer()).create();

This serializer is created within the ButterflyData class. It’s long, but all it really does is look for the values in the JSON file, and uses them to instantiate a new entry.

        /**
         * Deserializes a JSON object into a butterfly entry
         * @param json The Json data being deserialized
         * @param typeOfT The type of the Object to deserialize to
         * @param context Language context (ignored)
         * @return A new butterfly entry
         * @throws JsonParseException Unused
         */
        @Override
        public ButterflyData deserialize(JsonElement json,
                                         Type typeOfT,
                                         JsonDeserializationContext context) throws JsonParseException {
            ButterflyData entry = null;

            if (json instanceof final JsonObject object) {
                int index = object.get("index").getAsInt();
                String entityId = object.get("entityId").getAsString();

                String sizeStr = object.get("size").getAsString();
                Size size = Size.MEDIUM;
                if (Objects.equals(sizeStr, "small")) {
                    size = Size.SMALL;
                } else if (Objects.equals(sizeStr, "large")) {
                    size = Size.LARGE;
                }

                String speedStr = object.get("speed").getAsString();
                Speed speed = Speed.MODERATE;
                if (Objects.equals(speedStr, "fast")) {
                    speed = Speed.FAST;
                }

                String rarityStr = object.get("rarity").getAsString();
                Rarity rarity = Rarity.COMMON;
                if (Objects.equals(rarityStr, "uncommon")) {
                    rarity = Rarity.UNCOMMON;
                } else if (Objects.equals(rarityStr, "rare")) {
                    rarity = Rarity.RARE;
                }

                String habitatStr = object.get("habitat").getAsString();
                Habitat habitat = Habitat.PLAINS;
                if (Objects.equals(habitatStr, "forests")) {
                    habitat = Habitat.FORESTS;
                } else if (Objects.equals(habitatStr, "forests_and_plains")) {
                    habitat = Habitat.FORESTS_AND_PLAINS;
                } else if (Objects.equals(habitatStr, "jungles")) {
                    habitat = Habitat.JUNGLES;
                }

                JsonObject lifespan = object.get("lifespan").getAsJsonObject();

                String caterpillarStr = lifespan.get("caterpillar").getAsString();
                int caterpillarLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(caterpillarStr, "short")) {
                    caterpillarLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(caterpillarStr, "long")) {
                    caterpillarLifespan = LIFESPAN_LONG;
                }

                String chrysalisStr = lifespan.get("chrysalis").getAsString();
                int chrysalisLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(chrysalisStr, "short")) {
                    chrysalisLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(chrysalisStr, "long")) {
                    chrysalisLifespan = LIFESPAN_LONG;
                }

                String butterflyStr = lifespan.get("butterfly").getAsString();
                int butterflyLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(butterflyStr, "short")) {
                    butterflyLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(butterflyStr, "long")) {
                    butterflyLifespan = LIFESPAN_LONG;
                }

                entry = new ButterflyData(
                        index,
                        entityId,
                        size,
                        speed,
                        rarity,
                        habitat,
                        caterpillarLifespan,
                        chrysalisLifespan,
                        butterflyLifespan
                );
            }

            return entry;
        }

One final change I made was to remove the inner Entry class within ButterflyData. Now we just use the class itself which keeps the code cleaner. It’s tricky to highlight this change in a code snippet, but if you check out my GitHub repo you can see how I altered the class and references to it.

Testing


The final nerve-wracking part of this was to test the game. I had been debugging the code as I developed it to ensure the data was loaded into memory and was all correct, but now I needed to play an actual game and see if the data was still reflected in the butterflies as expected.

Thankfully, nerves were quickly unwracked as it seemed everything was working fine. Butterflies and caterpillars were the correct size, scrolls and books still looked correct. Everything just worked. It’s always nice when something works first time.

Of course, this doesn’t necessarily mean it’s all perfect, but it’s a great start. This change doesn’t really alter anything about how the mod looks or works for a player, so next week I’ll look at something a bit more important.