package electroblob.wizardry.client.gui.handbook; import com.google.common.collect.Streams; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import electroblob.wizardry.Wizardry; import electroblob.wizardry.client.DrawingUtils; import electroblob.wizardry.util.JavaUtils; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.FontRenderer; import net.minecraft.client.gui.GuiButton; import net.minecraft.client.renderer.GlStateManager; import net.minecraft.util.JsonUtils; import net.minecraft.util.ResourceLocation; import org.apache.commons.lang3.StringUtils; import java.util.*; /** * Instances of this class represent sections in the wizard's handbook. As of wizardry 4.2, this class handles * everything within the section itself, including JSON parsing, unlock triggers and drawing the actual text. * Sections may now also be nested and have other elements within them, such as images and a table of contents, a * behaviour which is also handled within this class. *

* The formatting of the book is now done 'dynamically' - that is, the exact positions and page numbers of * sections, images and so on are determined on GUI load and depend on which of the previous sections have been * unlocked, amongst other factors. This means that all of the unlocked sections must be formatted in order on GUI * load, so that each section knows the previous section's length and therefore where to start. * * @author Electroblob * @since Wizardry 4.2 */ // Because these are now generated on resource pack reload (not on handbook open, as before), this class can no longer // be a non-static inner class class Section { // Final fields are mandatory (none here though), the rest are optional String title; private String[] rawText; private Contents contents; private ResourceLocation[] triggers; private Map subsections; private boolean centreX, centreY; // Derived fields, not explicitly defined in JSON /** The single-page index of the first page of this section. */ int startPage; private final List> buttons; /** * A list of single pages, which are themselves lists of paragraphs (each paragraph is a single * string which may include line breaks and other escape characters). */ private final List> pages; private boolean unlocked = false; private boolean isNew = false; private Section(){ this.buttons = new ArrayList<>(); this.pages = new ArrayList<>(); this.subsections = new LinkedHashMap<>(); } Collection getButtons(){ return JavaUtils.flatten(buttons); } /** * Returns true if the given page is within this section, false if not (or if the section is locked). */ boolean containsPage(int page){ return this.isUnlocked() && startPage <= page && startPage + pages.size() > page; } /** * Returns true if this section is unlocked for the client player, false if not. Always returns true if * handbook progression is disabled in the config. */ boolean isUnlocked(){ if(Minecraft.getMinecraft().player.isCreative()) return true; if(!Wizardry.settings.handbookProgression) return true; // Always unlocked if handbook progression is off if(triggers == null) return true; // If no triggers were defined, the section is unlocked from the start // A section is automatically unlocked if one of its subsections is unlocked for(Section subsection : subsections.values()){ if(subsection.isUnlocked()) return true; } return unlocked; } /** * Returns true if this section has been unlocked and not read yet. Also returns true if any subsections are new. */ boolean isNew(){ if(!Wizardry.settings.handbookProgression) return false; return isNew || this.subsections.values().stream().anyMatch(Section::isNew); } /** * Actually draws the contents of the given section for the given double-page spread. Will do nothing if the * given page is outside of this section. * * @param font The font renderer object. * @param doublePage The index of the double-page to be drawn. * @param left The x coordinate of the left side of the GUI. * @param top The y coordinate of the top of the GUI. */ // This method is supposed to be 'idiot-proof' in the sense that the code calling it need not check whether the // section actually needs drawing, so it can just dumbly call draw(...) for all the sections in order. void draw(FontRenderer font, int doublePage, int left, int top){ // Show/hide buttons int i = 0; for(List list : buttons){ final int i1 = i++; list.forEach(b -> b.visible = GuiWizardHandbook.singleToDoublePage(startPage + i1) == doublePage); } int leftIndex = GuiWizardHandbook.doubleToSinglePage(doublePage, false); // Relative indices of the pages to be rendered - often these will be outside the section entirely int[] visiblePages = {leftIndex - startPage, leftIndex - startPage + 1}; for(int page : visiblePages){ if(page >= 0 && page < pages.size()){ List lines = pages.get(page); int x = left + (GuiWizardHandbook.isRightPage(startPage + page) ? GuiWizardHandbook.GUI_WIDTH - GuiWizardHandbook.TEXT_INSET_X - GuiWizardHandbook.PAGE_WIDTH : GuiWizardHandbook.TEXT_INSET_X); int y = top + GuiWizardHandbook.TEXT_INSET_Y; if(centreY) y += GuiWizardHandbook.PAGE_HEIGHT / 2 - lines.size() / 2 * font.FONT_HEIGHT; for(String line : lines){ if(line.startsWith(GuiWizardHandbook.FORMAT_MARKER + GuiWizardHandbook.RULER_TAG)){ GlStateManager.color(1, 1, 1, 1); Minecraft.getMinecraft().renderEngine.bindTexture(GuiWizardHandbook.texture); DrawingUtils.drawTexturedRect(x-1, y-1, 0, GuiWizardHandbook.GUI_HEIGHT, GuiWizardHandbook.PAGE_WIDTH + 2, 9, GuiWizardHandbook.TEXTURE_WIDTH, GuiWizardHandbook.TEXTURE_HEIGHT); }else{ int lx = centreX ? x + GuiWizardHandbook.PAGE_WIDTH / 2 - font.getStringWidth(line) / 2 : x; font.drawString(line, lx, y, DrawingUtils.BLACK, false); } y += font.FONT_HEIGHT; } isNew = false; // Now a page has been drawn, the player must have seen it so it's not new any more } } } /** * Called on GUI load to format the section, contents tables and other elements, excluding subsections. * Does not perform any actual drawing. * * @param font The font renderer object, for measurement purposes. * @param startPage The index of the first page (single side, not double-page) of this section. * @param left The x coordinate of the left side of the GUI. * @param top The y coordinate of the top of the GUI. * @return The single-page index of the next blank page after the end of this section. * @throws JsonSyntaxException if at any point the formatting is found to be invalid. */ int format(FontRenderer font, int startPage, int left, int top){ this.buttons.clear(); this.pages.clear(); // FONT_HEIGHT may change between fonts, so this is calculated here. With the default font it's 14. final int maxLineNumber = GuiWizardHandbook.PAGE_HEIGHT / font.FONT_HEIGHT; this.startPage = startPage; // First everything is added to a single list of lines, then it is split into pages. List lines = new ArrayList<>(); // Adds the header if present if(!this.title.isEmpty()){ lines.add(this.title); lines.add(GuiWizardHandbook.FORMAT_MARKER + GuiWizardHandbook.RULER_TAG); } // Adds space for the contents if it exists if(this.contents != null){ lines.addAll(Collections.nCopies(this.contents.format(font, startPage, lines.size(), left, top), "")); // Line break between contents and first paragraph if((lines.size() % maxLineNumber) != 0) lines.add(""); } if(this.rawText != null){ // Paragraphs are defined as a JSON list because it makes it easier to arrange them properly across pages // - using multiple line breaks would mean having to find and remove them when at the top of a page. for(String paragraph : this.rawText){ // (lines.size() % maxLineNumber) gives the number of lines on the current page // (lines.size() / maxLineNumber) gives the index of the current page minus the value of startPage // Formats the paragraph String raw = paragraph; // For error messages // Images (images must be separate paragraphs) if(paragraph.startsWith(GuiWizardHandbook.FORMAT_MARKER + GuiWizardHandbook.IMAGE_TAG)){ String[] arguments = paragraph.split("\\s", 2); if(arguments.length < 2) throw new JsonSyntaxException("Missing image name in string " + StringUtils.abbreviate(raw, 50)); Image image = GuiWizardHandbook.images.get(arguments[1]); if(image == null) throw new JsonSyntaxException("Image with id " + arguments[1] + " is undefined"); // Starts a new page if the image will not fit on the current one if((lines.size() % maxLineNumber) * font.FONT_HEIGHT + image.getHeight(font) > GuiWizardHandbook.PAGE_HEIGHT){ // Remaining number of lines on the page lines.addAll(Collections.nCopies(maxLineNumber - (lines.size() % maxLineNumber), "")); } if(image.getWidth() > GuiWizardHandbook.PAGE_WIDTH) Wizardry.logger.warn("Image with id " + arguments[1] + "has a width (" + image.getWidth() + ") greater than the maximum page width (" + GuiWizardHandbook.PAGE_WIDTH + "), it will extend beyond the page area."); if(image.getHeight(font) > GuiWizardHandbook.PAGE_HEIGHT) Wizardry.logger.warn("Image with id " + arguments[1] + "has a height (" + image.getHeight(font) + ") greater than the maximum page height (" + GuiWizardHandbook.PAGE_HEIGHT + "), it will extend beyond the page area."); int page = startPage + (lines.size() / maxLineNumber); image.addInstance(page, GuiWizardHandbook.PAGE_WIDTH / 2 - image.getWidth() / 2 + (GuiWizardHandbook.isRightPage(page) ? GuiWizardHandbook.GUI_WIDTH - GuiWizardHandbook.TEXT_INSET_X - GuiWizardHandbook.PAGE_WIDTH : GuiWizardHandbook.TEXT_INSET_X), GuiWizardHandbook.TEXT_INSET_Y + (lines.size() % maxLineNumber) * font.FONT_HEIGHT); // Height of the image in lines, rounded up // Uses a single space instead of an empty string so that the page trimming doesn't remove them lines.addAll(Collections.nCopies(image.getHeight(font) / font.FONT_HEIGHT, " ")); lines.add(""); // The last one is removable though, since it's actually extra space // Recipes (recipes must be separate paragraphs) }else if(paragraph.startsWith(GuiWizardHandbook.FORMAT_MARKER + GuiWizardHandbook.RECIPE_TAG)){ String[] arguments = paragraph.split("\\s", 2); if(arguments.length < 2) throw new JsonSyntaxException("Missing recipe name in string " + StringUtils.abbreviate(raw, 50)); CraftingRecipe recipe = GuiWizardHandbook.recipes.get(arguments[1]); if(recipe == null) throw new JsonSyntaxException("Recipe with id " + arguments[1] + " is undefined"); // Starts a new page if the recipe will not fit on the current one if((lines.size() % maxLineNumber) * font.FONT_HEIGHT + CraftingRecipe.HEIGHT > GuiWizardHandbook.PAGE_HEIGHT){ // Remaining number of lines on the page, plus the first blank one on the new page lines.addAll(Collections.nCopies(maxLineNumber - (lines.size() % maxLineNumber), " ")); } int page = startPage + (lines.size() / maxLineNumber); if(lines.size() % maxLineNumber == 0) lines.add(" "); int startLine = lines.size() % maxLineNumber - 1; recipe.addInstance(page, GuiWizardHandbook.PAGE_WIDTH / 2 - CraftingRecipe.WIDTH / 2 + (GuiWizardHandbook.isRightPage(page) ? GuiWizardHandbook.GUI_WIDTH - GuiWizardHandbook.TEXT_INSET_X - GuiWizardHandbook.PAGE_WIDTH : GuiWizardHandbook.TEXT_INSET_X), GuiWizardHandbook.TEXT_INSET_Y + startLine * font.FONT_HEIGHT); // Height of the recipe in lines, rounded up // Uses a single space instead of an empty string so that the page trimming doesn't remove them lines.addAll(Collections.nCopies(CraftingRecipe.HEIGHT / font.FONT_HEIGHT - 1, " ")); // This time we're not adding an extra space because it's not really needed }else{ // All other paragraphs // Formatting for(Map.Entry entry : GuiWizardHandbook.FORMAT_TAGS.entrySet()){ paragraph = paragraph.replace(GuiWizardHandbook.FORMAT_MARKER + entry.getKey(), entry.getValue()); } // Hyperlinks int linkStart; while((linkStart = paragraph.indexOf(GuiWizardHandbook.HYPERLINK_MARKER)) > -1){ // Ooh an assignment and a comparison in one... int linkEnd = paragraph.indexOf(GuiWizardHandbook.HYPERLINK_MARKER, linkStart + 1); if(linkEnd < 0) throw new JsonSyntaxException("Un-closed hyperlink marker in string " + StringUtils.abbreviate(raw, 50)); List upToLink = font.listFormattedStringToWidth(paragraph.substring(0, linkStart), GuiWizardHandbook.PAGE_WIDTH); String linkRaw = paragraph.substring(linkStart, linkEnd + 1); String[] arguments = paragraph.substring(linkStart + 1, linkEnd).split("\\s", 2); String suffix = paragraph.substring(linkEnd).split("\\s", 2)[0].substring(1); // substring(1) to remove the @ // The index of the single page currently being formatted, relative to the section int pageRelative = (lines.size() + upToLink.size() - 1) / maxLineNumber; // The overall index of the single page currently being formatted int page = startPage + pageRelative; // The line number on this page int lineNumber = (lines.size() + upToLink.size() - 1) % maxLineNumber; int x = GuiWizardHandbook.isRightPage(page) ? left + GuiWizardHandbook.GUI_WIDTH - GuiWizardHandbook.TEXT_INSET_X - GuiWizardHandbook.PAGE_WIDTH : left + GuiWizardHandbook.TEXT_INSET_X; int y = top + GuiWizardHandbook.TEXT_INSET_Y + lineNumber * font.FONT_HEIGHT; // Adds any missing sub-lists while(this.buttons.size() <= pageRelative){ this.buttons.add(new ArrayList<>()); } // The button id only does what you use it for, so we're just not using it at all. this.buttons.get(pageRelative).add(GuiButtonHyperlink.create(x, y, font, upToLink, arguments, suffix, maxLineNumber - lineNumber - 1, GuiWizardHandbook.isRightPage(page))); // The link button should exactly overlay the display text in the main string // If the link has no display text specified, it displays the unformatted target string paragraph = paragraph.replace(linkRaw, arguments[arguments.length - 1]); } lines.addAll(font.listFormattedStringToWidth(paragraph, GuiWizardHandbook.PAGE_WIDTH)); } // Line break between paragraphs (the last one will just be deleted later) if((lines.size() % maxLineNumber) != 0) lines.add(""); } } // Splits lines into pages List page = new ArrayList<>(); pages.add(page); while(!lines.isEmpty()){ if(page.size() == maxLineNumber){ // Removes blank lines at the end of the page while(page.get(page.size() - 1).isEmpty()) page.remove(page.size() - 1); // Adds a new page pages.add(page = new ArrayList<>()); } String line = lines.remove(0); // Prevents blank lines at the start of the page if(!page.isEmpty() || !line.isEmpty()) page.add(line); } return startPage + pages.size(); } /** * Parses the given JSON object and constructs a new {@code Section} from it, setting all the relevant fields * and references. This method converts the JSON object to a {@code Section} object and retrieves any resources; * the section is not formatted in any way until GUI load, in {@link Section#format(FontRenderer, int, int, int)}. * * @param json A JSON object representing the section to be constructed. This must contain at least a "title" * string. * @return The resulting {@code Section} object. * @throws JsonSyntaxException if at any point the JSON object is found to be invalid. */ static Section fromJson(JsonObject json){ Section section = new Section(); section.title = JsonUtils.getString(json, "title", ""); if(JsonUtils.hasField(json, "include_in_contents")){ String id = JsonUtils.getString(json, "include_in_contents"); Contents belongsTo = GuiWizardHandbook.contentsList.get(id); if(belongsTo == null){ throw new JsonSyntaxException("Expected include_in_contents to be the id of a previously defined contents, but no contents with the id " + id + " exists yet."); }else{ belongsTo.addEntry(section); } } if(JsonUtils.hasField(json, "contents")){ section.contents = Contents.fromJson(section, JsonUtils.getJsonObject(json, "contents")); GuiWizardHandbook.contentsList.put(section.contents.id, section.contents); } if(JsonUtils.hasField(json, "text")){ section.rawText = Streams.stream(JsonUtils.getJsonArray(json, "text")) .map(e -> JsonUtils.getString(e, "element of array text")) .toArray(String[]::new); } if(JsonUtils.hasField(json, "triggers")){ section.triggers = Streams.stream(JsonUtils.getJsonArray(json, "triggers")) .map(e -> new ResourceLocation(JsonUtils.getString(e, "element of array triggers"))) .toArray(ResourceLocation[]::new); // TODO: Can we validate this and throw a JSON exception if no such advancement exists? } if(JsonUtils.hasField(json, "centre")){ JsonObject centre = JsonUtils.getJsonObject(json,"centre"); section.centreX = JsonUtils.getBoolean(centre, "x", false); section.centreY = JsonUtils.getBoolean(centre, "y", false); } // The only benefit of having subsections (other than logical grouping) is that the parent section can // automatically be unlocked if one of the subsections is. if(JsonUtils.hasField(json, "sections")){ populate(section.subsections, json); } return section; } static void populate(Map map, JsonObject json){ JsonObject sectionsObject = JsonUtils.getJsonObject(json, "sections"); // Need to iterate over these since we don't know what they're called or how many there are for(Map.Entry entry : sectionsObject.entrySet()){ String key = entry.getKey(); // Find out what each element is called, this will be the sections map key Section section = fromJson(entry.getValue().getAsJsonObject()); map.put(key, section); map.putAll(section.subsections); } } /** * Called on login and advancement completion to update this section's unlock status and display toast * notifications if applicable. */ public void updateUnlockStatus(boolean showToasts, ResourceLocation... completedAdvancements){ if(triggers == null) return; List completed = new ArrayList<>(Arrays.asList(completedAdvancements)); completed.retainAll(Arrays.asList(triggers)); // Only shows the toast when the section was locked before and is now unlocked if(!this.unlocked && !completed.isEmpty() && showToasts && Wizardry.settings.handbookProgression){ // Mmmmm toast... Minecraft minecraft = Minecraft.getMinecraft(); minecraft.getToastGui().add(new HandbookToast(this)); this.isNew = true; } // Currently, this will not take subsections into account this.unlocked = !completed.isEmpty(); } }