200 Files and Nothing New

This week I merged a change consisting of over 200 files. It’s probably the largest change I’ve made since the project began, and yet it doesn’t actually introduce anything new. Instead, it fixes a mistake I made when I first created the mod.

The Mistake


Way back at the beginning, I added butterfly nets to the mod. A decision I made that seemed sensible at the time was to represent the butterfly contained in the net. After that I implemented other items using the same NBT tags, ensuring they were passed around by intercepting ItemCraftedEvent.

This seemed to work, but unfortunately led to some weird bugs when crafting, mainly to do with shift-clicking the resulting item. I implemented a quick fix for these issues, but even at the time I knew it was a hack. At some point in the future I was going to have to fix this properly. I knew it was going to be a tricky bug to fix, so I made a task on my backlog to remind myself to fix it.

This week, and nearly 2 months later, I finally got around to fixing it.

The Fix


The basic solution here was to have 1 item for each butterfly, rather than 1 item with an NBT. So there would be 17 butterfly nets in total: 1 empty net, plus one for each butterfly species. All other items would follow on from that, and recipes could then be specified for each group of items individually. This way, Minecraft’s native code could handle all of the crafting for us, and we would need any custom event handling.

It’s a solid fix, but this was going to be a big one to implement. In the end, I had to add or modify over 200 files. I broke up the change into phases:

  1. Item Construction
    Standardise item construction so that all butterfly items take and store a butterfly index.
  2. Item Registration
    Ensure one item of each type is registered for each butterfly species.
  3. Item Creation
    Ensure that everywhere in code where we create a butterfly item, we now create the new item instead of the old.
  4. Item Data
    Update the resources (item models, recipes, etc.) so they support the new items rather than the old.

Item Construction

The first step was to ensure we could construct our items. To start with I added a new method to the ButterflyContainerItem interface to force all items that use it to provide a butterfly index.

public interface ButterflyContainerItem {

    /**
     * Get the index for the species of butterfly related to this item.
     * @return The butterfly index.
     */
    int getButterflyIndex();

    // <Remaining Code Here>
}

This ensured I wouldn’t even be able to build the game until all the needed items were updated. The items I needed to update after this were BottledButterflyItem, ButterflyEggItem, ButterflyNetItem, and ButterflyScrollItem.

For each of these items I added names to be used for registration. I made sure to keep the old names as well. In this way, when people update the mod, their old items will still work, and will eventually be replaced with the new items.

    //  The name this item is registered under.
    public static final String EMPTY_NAME = "butterfly_net";
    public static final String ADMIRAL_NAME = "butterfly_net_admiral";
    public static final String BUCKEYE_NAME = "butterfly_net_buckeye";
    public static final String CABBAGE_NAME = "butterfly_net_cabbage";
    public static final String CHALKHILL_NAME = "butterfly_net_chalkhill";
    public static final String CLIPPER_NAME = "butterfly_net_clipper";
    public static final String COMMON_NAME = "butterfly_net_common";
    public static final String EMPEROR_NAME = "butterfly_net_emperor";
    public static final String FORESTER_NAME = "butterfly_net_forester";
    public static final String GLASSWING_NAME = "butterfly_net_glasswing";
    public static final String HAIRSTREAK_NAME = "butterfly_net_hairstreak";
    public static final String HEATH_NAME = "butterfly_net_heath";
    public static final String LONGWING_NAME = "butterfly_net_longwing";
    public static final String MONARCH_NAME = "butterfly_net_monarch";
    public static final String MORPHO_NAME = "butterfly_net_morpho";
    public static final String RAINBOW_NAME = "butterfly_net_rainbow";
    public static final String SWALLOWTAIL_NAME = "butterfly_net_swallowtail";

    //  TODO: Remove this item
    public static final String FULL_NAME = "butterfly_net_full";

I then needed to add the butterfly index. I had each class store it as a value, modified the constructor, and implemented the required accessor. Each registry entry will create a new instance of the class, so we are able to store it here rather than on an ItemStack.

    //  The index of the butterfly species.
    private final int butterflyIndex;

    /**
     * Construction
     * @param properties The item properties.
     * @param butterflyIndex The index of the butterfly species.
     */
    public ButterflyNetItem(int butterflyIndex) {
        super(new Item.Properties().stacksTo(1));

        this.butterflyIndex = butterflyIndex;
    }

    /**
     * Get the butterfly index.
     * @return The butterfly index.
     */
    @Override
    public int getButterflyIndex() {
        return this.butterflyIndex;
    }

I also took advantage of this opportunity to simplify the various constructors, since I was digging into this code anyway.

With these changes in place, I could move on to the next step.

Item Registration

Next, we need to register all the new items. Using the new constructors, I pass in the butterfly index to all the constructors. We use -1 as the butterfly index for the empty net, since it doesn’t have a butterfly in it.

We keep the old items and use -1 for the butterfly index as well. Later we will make sure these items use the NBT tags by default so that they won’t break old worlds.

    //  Butterfly net - Used to catch butterflies
    public static final RegistryObject<Item> BUTTERFLY_NET = INSTANCE.register(ButterflyNetItem.EMPTY_NAME,
            () -> new ButterflyNetItem(-1));
    public static final RegistryObject<Item> BUTTERFLY_NET_ADMIRAL = INSTANCE.register(ButterflyNetItem.ADMIRAL_NAME,
            () -> new ButterflyNetItem(0));
    public static final RegistryObject<Item> BUTTERFLY_NET_BUCKEYE = INSTANCE.register(ButterflyNetItem.BUCKEYE_NAME,
            () -> new ButterflyNetItem(1));
    public static final RegistryObject<Item> BUTTERFLY_NET_CABBAGE = INSTANCE.register(ButterflyNetItem.CABBAGE_NAME,
            () -> new ButterflyNetItem(2));
    // <etc>

    // TODO: This is the old implementation, included for backwards
    //       compatibility. This needs to be removed in a future version.
    public static final RegistryObject<Item> BUTTERFLY_NET_FULL = INSTANCE.register(ButterflyNetItem.FULL_NAME,
            () -> new ButterflyNetItem(-1));

Another change we make is that bottled butterflies have their own block for each species. Doing this allows us to use the loot tables properly, and means we don’t need as much code to handle drops. As with the items, we keep the original block for backwards compatibility.

    // Bottled Butterflies
    public static final RegistryObject<Block> BOTTLED_BUTTERFLY_ADMIRAL =
            INSTANCE.register(BottledButterflyBlock.ADMIRAL_NAME, BottledButterflyBlock::new);
    public static final RegistryObject<Block> BOTTLED_BUTTERFLY_BUCKEYE =
            INSTANCE.register(BottledButterflyBlock.BUCKEYE_NAME, BottledButterflyBlock::new);
    public static final RegistryObject<Block> BOTTLED_BUTTERFLY_CABBAGE =
            INSTANCE.register(BottledButterflyBlock.CABBAGE_NAME, BottledButterflyBlock::new);
    // <etc>

We also add some helper methods to the item registry to convert a butterfly index to an item. These methods are useful in various places in the code. As an example, the Bottled Butterfly method looks like this:

    /**
     * Helper method to get the correct bottled butterfly item.
     * @param butterflyIndex The butterfly index.
     * @return The registry entry for the related item.
     */
    public static RegistryObject<Item> getBottledButterflyFromIndex(int butterflyIndex) {
        return switch (butterflyIndex) {
            case 0 -> BOTTLED_BUTTERFLY_ADMIRAL;
            case 1 -> BOTTLED_BUTTERFLY_BUCKEYE;
            case 2 -> BOTTLED_BUTTERFLY_CABBAGE;
            case 3 -> BOTTLED_BUTTERFLY_CHALKHILL;
            case 4 -> BOTTLED_BUTTERFLY_CLIPPER;
            case 5 -> BOTTLED_BUTTERFLY_COMMON;
            case 6 -> BOTTLED_BUTTERFLY_EMPEROR;
            case 7 -> BOTTLED_BUTTERFLY_FORESTER;
            case 8 -> BOTTLED_BUTTERFLY_GLASSWING;
            case 9 -> BOTTLED_BUTTERFLY_HAIRSTREAK;
            case 10 -> BOTTLED_BUTTERFLY_HEATH;
            case 11 -> BOTTLED_BUTTERFLY_LONGWING;
            case 12 -> BOTTLED_BUTTERFLY_MONARCH;
            case 13 -> BOTTLED_BUTTERFLY_MORPHO;
            case 14 -> BOTTLED_BUTTERFLY_RAINBOW;
            case 15 -> BOTTLED_BUTTERFLY_SWALLOWTAIL;
            default -> null;
        };
    }

We also update the BuildCreativeModeTabContentsEvent handler so that the new items will appear in the creative menu.

Item Creation

Now, we need to make sure that anywhere in code where we create an item, we create the new version of the item instead. Since we will be using recipes properly now, we can remove the hacky code in our ItemCraftedEvent handler. The butterfly book still works the same way so we keep the code that handles that.

We don’t remove the code from the butterfly blocks. Instead, we modify it so that it will only create a drop if the block entity has a butterfly set. Otherwise, we rely on the loot table.

    /**
     * Transfer the NBT data to the drops when the block is destroyed.
     * TODO: Included only for backward compatibility. This should be removed
     *       in a future version.
     * @param blockState The current block state.
     * @param builder The loot drop builder.
     * @return The loot dropped by this block.
     */
    @NotNull
    @Override
    @SuppressWarnings("deprecation")
    public List<ItemStack> getDrops(@NotNull BlockState blockState,
                                    @NotNull LootParams.Builder builder) {
        BlockEntity blockEntity = builder.getOptionalParameter(LootContextParams.BLOCK_ENTITY);
        if (blockEntity instanceof ButterflyBlockEntity butterflyBlockEntity) {
            ResourceLocation entity = butterflyBlockEntity.getEntityLocation();
            if (entity != null) {
                int butterflyIndex = ButterflyData.getButterflyIndex(butterflyBlockEntity.getEntityLocation());
                ItemStack stack = new ItemStack(ItemRegistry.getBottledButterflyFromIndex(butterflyIndex).get());
                List<ItemStack> result = Lists.newArrayList();
                result.add(stack);
                return result;
            }
        }

        return super.getDrops(blockState, builder);
    }

With the bottled butterfly item, we rewrite our place() method so that it no longer assigns a butterfly to the block entity. This means that any new blocks placed will ignore the above code, and rely solely on the loot table.

    /**
     * Placing the item will create an in-world bottle with a butterfly inside.
     * @param context The context in which the block is being placed.
     * @return The interaction result.
     */
    @Override
    @NotNull
    public InteractionResult place(@NotNull BlockPlaceContext context) {

        InteractionResult result = super.place(context);
        if (result == InteractionResult.CONSUME) {

            Player player = context.getPlayer();
            if (player != null) {
                ItemStack stack = player.getItemInHand(context.getHand());
                ResourceLocation entity = getButterflyEntity(stack);

                if (entity != null) {
                    BlockPos position = context.getClickedPos();
                    Butterfly.spawn(player.level(), entity, position, true);
                }
            }
        }

        return result;
    }

In ButterflyNetItem we modify the onLeftClickEntity method so that it replaces the item in hand with the corresponding item for the butterfly index. This makes the old Full Butterfly Net item unobtainable without commands.

    /**
     * If we left-click on a butterfly with an empty net, the player will catch the butterfly.
     * @param stack  The Item being used
     * @param player The player that is attacking
     * @param entity The entity being attacked
     * @return TRUE if the left-click action is consumed.
     */
    @Override
    public boolean onLeftClickEntity(ItemStack stack, Player player, Entity entity) {
        if (entity instanceof Butterfly butterfly) {

            RegistryObject<Item> item = ItemRegistry.getButterflyNetFromIndex(butterfly.getButterflyIndex());
            if (item != null) {
                ItemStack newStack = new ItemStack(item.get(), 1);

                entity.discard();

                player.setItemInHand(InteractionHand.MAIN_HAND, newStack);
                player.playSound(SoundEvents.PLAYER_ATTACK_SWEEP, 1F, 1F);

                return true;
            }
        }

        return super.onLeftClickEntity(stack, player, entity);
    }

The ButterflyScroll entity is also modified so that it drops the new scroll item rather than the old.

    /**
     * Drop a Butterfly Scroll when this gets destroyed
     * @param entity The entity being destroyed.
     */
    @Override
    public void dropItem(@Nullable Entity entity) {
        ItemStack stack = new ItemStack(ItemRegistry.getButterflyScrollFromIndex(this.butterflyIndex).get());
        this.spawnAtLocation(stack);
    }

This covers anywhere in code that we create an item. Now all we have left is data. Lots and lots of data.

Item Data

Now this is the hard part. We need new block states, models, loot tables, and recipes (and recipe advancements). 16 for each item, and 3 items. Only one thing for it. I create one json file named xxxx_admiral.json. I fill it with the correct data. Copy, paste, name it xxxx_buckeye.json. Replace admiral with buckeye in the file. Copy, paste, name it xxxx_cabbage.json. Replace admiral with cabbage in the file. Copy, paste, name it xxx....

Wait, what in the fridge am I doing? This is going to take forever. I’m lazy, and I’m already getting bored. And what happens if I add more species in the future? How many more of these copy/paste/replace files am I going to need to create. I’m lazy at heart, and I’m already bored.

Luckily I’m a programmer. And this is something that is easy to automate. I like to use Python for simple file operations like this. I have a script that helps me edit images for my Baldur’s Gate Let’s Play, I can do the same here.

I create a quick test environment and get working on the script. After a short time, I have this script ready to go:

import os
import pathlib
import shutil

# The list of butterfly species included in the mod.
BUTTERFLIES = [
    'admiral', 
    'buckeye',
    'cabbage',
    'chalkhill',
    'clipper',
    'common',
    'emperor',
    'forester',
    'glasswing',
    'hairstreak',
    'heath',
    'longwing',
    'monarch',
    'morpho',
    'rainbow',
    'swallowtail'
]

# Generate butterfly data files that don't exist yet
def generate_butterfly_files():
    # Get list of files containing BUTTERFLIES[0]
    files = []
    for (path, _, filenames) in os.walk(os.getcwd()):
    
        # We only want json
        filenames = [ f for f in filenames if f.endswith(".json") and BUTTERFLIES[0] in f ]
        
        for name in filenames:
            files.append(pathlib.Path(path, name))
    
    # Loop Start
    for butterfly in BUTTERFLIES:
        
        # We don't need to do this for the template
        if butterfly == BUTTERFLIES[0]:
            continue
            
        for file in files:
            
            # Get the new filename
            newfile = pathlib.Path(str(file).replace(BUTTERFLIES[0], butterfly))
            
            # Check if the file exists            
            if not newfile.is_file():
                
                # Create the new file if it doesn't exist
                shutil.copy(file, newfile)
                
                # Read in the new file
                with open(newfile, 'r') as file:
                  filedata = file.read()

                # Replace the butterfly species
                filedata = filedata.replace(BUTTERFLIES[0], butterfly)

                # Write the file out again
                with open(newfile, 'w') as file:
                  file.write(filedata)

# Python's main entry point
if __name__ == "__main__":
    generate_butterfly_files()

Basically the script looks for any json files containing BUTTERFLIES[0] (i.e. “admiral“) in the name. It then makes copy of the file for every other species in the list, but only if the file doesn’t already exist. This way, if I need to modify a file after its been generated, it won’t be overwritten if I run the script again.

Finally the script also replaces the text in the files as well. So butterfly_scroll_hairstreak.json will reference only hairstreak items and nothing else.

Now all I need to do is to create the files I need for the admiral butterfly only. Then I can run this script from my /resources/ folder and it will generate the same files for every other butterfly. Over 150 files were generated with this script, saving me a lot of time.

Now, if I decide to add more butterfly species, I can add the species to the list and run the script again. This will generate most of the data files I need for the butterfly.

It doesn’t help with translation strings, textures, and I’ll still need to edit the files loaded by ButterflyData, but this script still saves a LOT of work. With some of the features I have planned in the backlog, I’m very happy to have this script.

The Result


After doing all this work, the result is that there aren’t any new features really. But this is going to make a lot of things easier going forward. My custom recipes will no longer break the game due to hacky code, and the items work better in general with vanilla systems.

The biggest benefit we have from all this is in the Creative Menu. Before this change, if you wanted a specific butterfly in your butterfly net, or a specific butterfly scroll, you had to spawn the ingredients, catch the butterfly, and create it manually.

Now, we can just create them directly from the Creative Inventory. And that’s much more convenient for both players and testing.

One thought on “200 Files and Nothing New

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.