I’ve finished the last feature and released version 2 of the mod. The last feature I implemented was a book where you can store butterflies and gain information about them. The book has dynamically generated content, depending on which butterflies you store inside it.
The last change for this version implements a book that contains information on any butterflies the player has caught. By inserting a scroll into a book, the player can create a butterfly book. More scrolls can be added as the player catches more butterflies.
Data
The first thing to add is the data that will be used to describe the butterflies in the book. We add some localisation strings that can describe a butterfly’s size, speed, habitat and so on. We may only have English at the moment, but by continuing to use localisation strings there is potential for this mod to support other languages in the future.
"gui.butterflies.size": "Size: ", "gui.butterflies.size.small": "Small", "gui.butterflies.size.medium": "Medium", "gui.butterflies.size.large": "Large", "gui.butterflies.speed": "Speed: ", "gui.butterflies.speed.moderate": "Moderate", "gui.butterflies.speed.fast": "Fast", "gui.butterflies.rarity": "Rarity: ", "gui.butterflies.rarity.common": "Common", "gui.butterflies.rarity.uncommon": "Uncommon", "gui.butterflies.rarity.rare": "Rare", "gui.butterflies.lifespan": "Lifespan: ", "gui.butterflies.lifespan.short": "Short", "gui.butterflies.lifespan.average": "Average", "gui.butterflies.lifespan.long": "Long", "gui.butterflies.habitat": "Habitat: ", "gui.butterflies.habitat.forests": "Forests", "gui.butterflies.habitat.forestsandplains": "Forests/Plains", "gui.butterflies.habitat.jungles": "Jungle", "gui.butterflies.habitat.plains": "Plains", "gui.butterflies.fact.admiral": "Admiral butterflies like to perch on sunlit spots.", "gui.butterflies.fact.buckeye": "Buckeye butterflies can lay their eggs on many plants.", "gui.butterflies.fact.cabbage": "Cabbage butterflies can fly erratically.", "gui.butterflies.fact.chalkhill": "Chalkhill caterpillars are often protected by ants.", "gui.butterflies.fact.clipper": "Clipper butterflies can be many different colours.", "gui.butterflies.fact.common": "Th common butterfly can be found almost anywhere.", "gui.butterflies.fact.emperor": "Emperor butterflies like to eat tree sap.", "gui.butterflies.fact.forester": "This pink butterfly isn't scared of cats.", "gui.butterflies.fact.glasswing": "Glasswings can be hard to spot for predators.", "gui.butterflies.fact.hairstreak": "Hairstreaks like to choose a tree to be their home.", "gui.butterflies.fact.heath": "Heath butterflies dislike the dark and like to stay low.", "gui.butterflies.fact.longwing": "A longwing's lifespan is as long as its wings.", "gui.butterflies.fact.monarch": "Monarch butterflies can be poisonous.", "gui.butterflies.fact.morpho": "A morpho's blue colour comes from tiny scales on its wings.", "gui.butterflies.fact.rainbow": "Rainbow butterflies have only been recently discovered.", "gui.butterflies.fact.swallowtail": "Swallowtails are the largest butterflies in the world."
We can then look up these strings based on the values in a ButterflyData.Entry
. Since some of this information doesn’t exist there yet, we need to add some more enumerations.
// Represents the rarity of a butterfly. Note that this only affects the // description. The actual rarity is defined by biome modifiers. public enum Rarity { COMMON, UNCOMMON, RARE } // Represents a butterflies preferred habitat.Note that like rarity, this // only affects the description. The biome modifiers will determine where // they will actually spawn. public enum Habitat { FORESTS, FORESTS_AND_PLAINS, JUNGLES, PLAINS } // Helper enum to determine a butterflies overall lifespan. public enum Lifespan { SHORT, MEDIUM, LONG }
The Entry
class is updated to contain a rarity
and habitat
value. As the comments make clear, these values are actually defined in biome modifiers, so these are technically duplicated data.
For the lifespan, rather than adding a value, we actually create a helper method so it’s based on the actual lifespans of the butterfly.
/** * Get the overall lifespan as a simple enumeration * @return A representation of the lifespan. */ public Lifespan getOverallLifeSpan() { int days = (caterpillarLifespan + chrysalisLifespan + butterflyLifespan) / 24000; if (days < 15) { return Lifespan.SHORT; } else if(days < 25) { return Lifespan.MEDIUM; } else { return Lifespan.LONG; } }
This means we don’t need to worry about updating this value manually if we make any changes to a butterfly’s lifespan.
Finally we can set these values in the static initializer for the class. For example, the admiral butterfly is initialised like so:
addButterfly(0, "admiral", Size.MEDIUM, Speed.MODERATE, Rarity.COMMON, Habitat.FORESTS, LIFESPAN_SHORT, LIFESPAN_MEDIUM, LIFESPAN_MEDIUM);
Book Item
The ButterflyBookItem
is a relatively simple item that has a maximum stack size of 1 (i.e. it doesn’t stack).
public class ButterflyBookItem extends Item { public static final String NAME = "butterfly_book"; /** * Construction */ public ButterflyBookItem() { super(new Item.Properties().stacksTo(1)); } }
The other thing it does is opens a screen when a player uses it. This screen will display the collected butterflies and their information. Note that the snippet below looks a bit different to the PR linked here. It includes a fix implemented later that I’ll discuss below.
/** * Open the GUI when the item is used. * @param level The current level. * @param player The current player. * @param hand The hand holding the item. * @return The interaction result (always SUCCESS). */ @Override @NotNull public InteractionResultHolder<ItemStack> use(Level level, Player player, @NotNull InteractionHand hand) { ItemStack itemStack = player.getItemInHand(hand); if (level.isClientSide()) { openScreen(itemStack); } player.awardStat(Stats.ITEM_USED.get(this)); return InteractionResultHolder.sidedSuccess(itemStack, level.isClientSide()); } /** * Open the screen. Kept separate so it can be excluded from server builds. * @param book The book to display. */ @OnlyIn(Dist.CLIENT) private void openScreen(ItemStack book) { Minecraft.getInstance().setScreen(new ButterflyBookScreen(book)); }
Outside of the code I add the usual model, texture and localisation strings, as well as the recipes for the book.
Crafting the Book
To craft the book we need two recipes. The first is the default recipe that uses a normal book and a butterfly scroll.
{ "type": "minecraft:crafting_shapeless", "group": "butterfly_book", "ingredients": [ { "item": "butterflies:butterfly_scroll" }, { "item": "minecraft:book" } ], "result": { "item": "butterflies:butterfly_book" } }
The second is a recipe that will allow the player to add pages to a butterfly book.
{ "type": "minecraft:crafting_shapeless", "group": "butterfly_book", "ingredients": [ { "item": "butterflies:butterfly_scroll" }, { "item": "butterflies:butterfly_book" } ], "result": { "item": "butterflies:butterfly_book" } }
In order for this to work properly we need to modify our ItemCraftedEvent
listener so that it looks out for these recipes as well. This will figure out what butterfly we are trying to add and call a method that will update the NBT data.
} else if (craftingItem.getItem() instanceof ButterflyBookItem) { Container craftingMatrix = event.getInventory(); int index = -1; ItemStack oldBook = null; for (int i = 0; i < craftingMatrix.getContainerSize(); ++i) { // If there is a book then we are adding a page. ItemStack recipeItem = craftingMatrix.getItem(i); if (recipeItem.getItem() instanceof ButterflyBookItem) { oldBook = recipeItem; } CompoundTag tag = recipeItem.getTag(); if (tag != null && tag.contains(CompoundTagId.ENTITY_ID)) { ResourceLocation location = new ResourceLocation(tag.getString(CompoundTagId.ENTITY_ID)); index = ButterflyData.locationToIndex(location); } } if (index >= 0) { ButterflyBookItem.addPage(oldBook, craftingItem, index); }
We create a method to update the NBT tags if a page is added to it within the BookItem
class. Once all 16 pages are added it sets the CustomModelData
that will be used to unlock an achievement.
/** * Add a new page to the butterfly book. * @param oldBook The original book, if any. * @param newBook The book being crafted. * @param index The butterfly index. */ public static void addPage(ItemStack oldBook, ItemStack newBook, int index) { ListTag newPages = new ListTag(); if (oldBook != null) { CompoundTag tag = oldBook.getOrCreateTag(); if (tag.contains(CompoundTagId.PAGES)) { newPages = tag.getList(CompoundTagId.PAGES, 3); } } if (!newPages.contains(IntTag.valueOf(index))) { newPages.add(IntTag.valueOf(index)); } CompoundTag newTag = newBook.getOrCreateTag(); newTag.put(CompoundTagId.PAGES, newPages); if (newPages.size() >= 16) { newTag.putInt(CompoundTagId.CUSTOM_MODEL_DATA, 1); } }
The nice thing about this is that the pages in the book will be in the order the player adds them. This helps make the book feel unique to each player.
The Screen
The book has no function without a screen to display the actual information. The class for this is a copy of BookViewScreen
from the vanilla code, with some modifications. I wanted to inherit from the BookViewScreen
class, but unfortunately I needed access to data members that were private in that class. Rather than dealing with reflection, I figured it was easier to make a copy.
The class needed a custom render method that would allow it to display images as well as text. Essentially, every even numbered page would be an image, and ever odd numbered page would be a description of the butterfly. The images used here are the exact same images used for the butterfly scroll, just overlaid over the default book texture.
/** * Render the screen. * @param guiGraphics The graphics buffer for the gui. * @param x The x position of the cursor. * @param y The y position of the cursor. * @param unknown Unknown. */ @Override public void render(@NotNull GuiGraphics guiGraphics, int x, int y, float unknown) { // Render the background and book image. this.renderBackground(guiGraphics); int i = (this.width - 192) / 2; guiGraphics.blit(BOOK_LOCATION, i, 2, 0, 0, 192, 192); // If the page has changed, then update the cache. if (this.cachedPage != this.currentPage) { FormattedText formattedText = this.bookAccess.getPage(this.currentPage); this.cachedPageComponents = this.font.split(formattedText, 114); this.pageMsg = Component.translatable("book.pageIndicator", this.currentPage + 1, Math.max(this.getNumPages(), 1)); } this.cachedPage = this.currentPage; // If this is an even numbered page, display an image. if (this.cachedPage % 2 == 0) { int butterflyIndex = bookAccess.getButterflyIndex(cachedPage); guiGraphics.blit(ButterflyScrollTexture.TEXTURES[butterflyIndex], i, 2, 0, 0, 192, 192); // Otherwise it's an odd page so render descriptive text. } else { int cachedPageSize = Math.min(128 / 9, this.cachedPageComponents.size()); for (int line = 0; line < cachedPageSize; ++line) { FormattedCharSequence formattedCharSequence = this.cachedPageComponents.get(line); guiGraphics.drawString(this.font, formattedCharSequence, i + 36, 32 + line * 9, 0, false); } } // Render the page number. int fontWidth = this.font.width(this.pageMsg); guiGraphics.drawString(this.font, this.pageMsg, i - fontWidth + 192 - 44, 18, 0, false); // Highlight the UI elements properly. Style style = this.getClickedComponentStyleAt(x,y); if (style != null) { guiGraphics.renderComponentHoverEffect(this.font, style, x, y); } super.render(guiGraphics, x, y, unknown); }
So if we have a butterfly in the book, we can flip through and see images of the butterflies contained inside.
The description comes from the BookAccess
class, that contains the data for the book the player is currently reading. It is constructed from the ItemStack
passed into the screen, and pulls out the NBT data for the book’s pages.
/** * Class that contains a list of all the pages available in this book. */ @OnlyIn(Dist.CLIENT) public static class BookAccess { private ListTag pages; /** * Construct access based on an item stack. * @param stack The item stack we are using. */ public BookAccess(ItemStack stack) { CompoundTag tag = stack.getTag(); if (tag != null) { pages = tag.getList(CompoundTagId.PAGES, 3); } } /** * Get the butterfly index for the page. * @param page The page to get. * @return The index of the butterfly on the page. */ public int getButterflyIndex(int page) { if (pages != null) { return pages.getInt(page / 2); } return 0; } /** * Get the specified page * @param page The page number to get. * @return The formatted text for the page. */ public FormattedText getPage(int page) { return page >= 0 && page < this.getPageCount() ? this.getPageRaw(page) : FormattedText.EMPTY; } /** * Get the number of pages available in this book. * @return The total page count. */ public int getPageCount() { if (pages != null) { return pages.size() * 2; } return 0; } }
The actual text for the description is generated based on the ButterflyData.Entry
referenced by the page. We basically just choose different localisation strings based on the data. This means that if we update the data, the description will automatically change as well.
/** * Get the specified page for the book. * @param page The page number to get. * @return The formatted text for the page. */ public FormattedText getPageRaw(int page) { if (pages != null) { int butterflyIndex = pages.getInt((page - 1) / 2); ButterflyData.Entry entry = ButterflyData.getEntry(butterflyIndex); if (entry != null) { // Butterfly name MutableComponent component = Component.translatable("entity.butterflies." + entry.entityId); // Rarity component.append("\n\n"); component.append(Component.translatable("gui.butterflies.rarity")); switch (entry.rarity) { case RARE -> component.append(Component.translatable("gui.butterflies.rarity.rare")); case UNCOMMON -> component.append(Component.translatable("gui.butterflies.rarity.uncommon")); case COMMON -> component.append(Component.translatable("gui.butterflies.rarity.common")); default -> {} } // Size component.append("\n"); component.append(Component.translatable("gui.butterflies.size")); switch (entry.size) { case SMALL -> component.append(Component.translatable("gui.butterflies.size.small")); case MEDIUM -> component.append(Component.translatable("gui.butterflies.size.medium")); case LARGE -> component.append(Component.translatable("gui.butterflies.size.large")); default -> {} } // Speed component.append("\n"); component.append(Component.translatable("gui.butterflies.speed")); switch (entry.speed) { case MODERATE -> component.append(Component.translatable("gui.butterflies.speed.moderate")); case FAST -> component.append(Component.translatable("gui.butterflies.speed.fast")); default -> {} } // Lifespan component.append("\n"); component.append(Component.translatable("gui.butterflies.lifespan")); switch (entry.getOverallLifeSpan()) { case SHORT -> component.append(Component.translatable("gui.butterflies.lifespan.short")); case MEDIUM -> component.append(Component.translatable("gui.butterflies.lifespan.average")); case LONG -> component.append(Component.translatable("gui.butterflies.lifespan.long")); default -> {} } // Habitat component.append("\n"); component.append(Component.translatable("gui.butterflies.habitat")); switch (entry.habitat) { case FORESTS -> component.append(Component.translatable("gui.butterflies.habitat.forests")); case FORESTS_AND_PLAINS -> component.append(Component.translatable("gui.butterflies.habitat.forestsandplains")); case JUNGLES -> component.append(Component.translatable("gui.butterflies.habitat.jungles")); case PLAINS -> component.append(Component.translatable("gui.butterflies.habitat.plains")); default -> {} } // Fact component.append("\n\n"); component.append(Component.translatable("gui.butterflies.fact." + entry.entityId)); return component; } } return null; }
Now we can see automatically generated descriptions of each butterfly in the mod.
Catch Em All
Finally, we add an achievement for collecting all the butterflies in a book. This also counts as collecting all 16 butterfly scrolls – if we add them all to a book we get the achievement.
{ "parent": "butterflies:butterfly/create_butterfly_scroll", "display": { "icon": { "item": "butterflies:butterfly_book" }, "title": { "translate": "advancements.butterfly.butterfly_book.title" }, "description": { "translate": "advancements.butterfly.catch_all_butterflies.description" }, "frame": "goal", "show_toast": true, "announce_to_chat": true, "hidden": false }, "rewards": { "experience": 100 }, "criteria": { "filled_book": { "trigger": "minecraft:inventory_changed", "conditions": { "items": [ { "items": [ "butterflies:butterfly_scroll" ], "nbt": "{CustomModelData:1}" } ] } } }, "requirements": [ [ "filled_book" ] ] }
With this done, we are ready for version 2.0.0 to release. It’s already been out for a couple of days, so go check it out!
Server Crash
Those of you who are more attentive may have noticed it’s already up to version 2.0.1. Someone reported a server crash with the new version, so I took some time to fix it. The bug report had a log attached so I could see right away what the problem was.
Mod File: /home/container/mods/butterflies-2.0.0-for-1.20.1.jar Failure message: Butterfly Mod (butterflies) has failed to load correctly java.lang.BootstrapMethodError: java.lang.RuntimeException: Attempted to load class net/minecraft/client/gui/screens/Screen for invalid dist DEDICATED_SERVER Mod Version: 2.0.0 Mod Issue URL: NOT PROVIDED Exception message: java.lang.RuntimeException: Attempted to load class net/minecraft/client/gui/screens/Screen for invalid dist DEDICATED_SERVER
The server was trying to load a client-only class, but why. I checked the classes in my client folder and found that many were missing the @OnlyIn(Dist.CLIENT)
annotation. I fixed those, ran a local server to test, and the same crash. What was going on?
I noticed that the error was coming from the registry. Double clicking on the error brought me to the line where I registered the butterfly book. After this I was able to figure out what was wrong pretty quickly. It was down to the way I was opening the screen.
/** * Open the GUI when the item is used. * @param level The current level. * @param player The current player. * @param hand The hand holding the item. * @return The interaction result (always SUCCESS). */ @Override @NotNull public InteractionResultHolder<ItemStack> use(Level level, Player player, @NotNull InteractionHand hand) { ItemStack itemStack = player.getItemInHand(hand); if (level.isClientSide()) { Minecraft.getInstance().setScreen(new ButterflyBookScreen(itemStack)); } player.awardStat(Stats.ITEM_USED.get(this)); return InteractionResultHolder.sidedSuccess(itemStack, level.isClientSide()); }
There is a check for the game being on the client side, but that call to setScreen
causes the server to try and compile the Screen
class, which doesn’t exist in the server’s code. After I figured this out, the fix was simple. We just need to move that call to a place where it would only exist on the client (this is the same code snippet as seen above).
/** * Open the GUI when the item is used. * @param level The current level. * @param player The current player. * @param hand The hand holding the item. * @return The interaction result (always SUCCESS). */ @Override @NotNull public InteractionResultHolder<ItemStack> use(Level level, Player player, @NotNull InteractionHand hand) { ItemStack itemStack = player.getItemInHand(hand); if (level.isClientSide()) { openScreen(itemStack); } player.awardStat(Stats.ITEM_USED.get(this)); return InteractionResultHolder.sidedSuccess(itemStack, level.isClientSide()); } /** * Open the screen. Kept separate so it can be excluded from server builds. * @param book The book to display. */ @OnlyIn(Dist.CLIENT) private void openScreen(ItemStack book) { Minecraft.getInstance().setScreen(new ButterflyBookScreen(book)); }
Now it won’t compile on the server any more, and since we have that check for level.isClientSide()
, the server will never try and call the function. I tested it one more time to make sure it worked, and now we have version 2.0.1 released before 2.0.0 was cool.
v3.0.0
I already have features for the next version planned. I’ll leave a small clue for what’s coming next, as I did last time.
Night