Files
Wizardry/src/main/java/electroblob/wizardry/client/gui/handbook/Section.java
T
Electroblob77 e3130ca936 Cleanup
2020-12-20 18:57:23 +00:00

452 lines
19 KiB
Java

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.
* <p></p>
* 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<String, Section> subsections;
private boolean centreX, centreY;
// Derived fields, not explicitly defined in JSON
/** The <b>single-page</b> index of the first page of this section. */
int startPage;
private final List<List<GuiButton>> buttons;
/**
* A list of <b>single</b> 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<List<String>> pages;
private boolean unlocked = false;
private boolean isNew = false;
private Section(){
this.buttons = new ArrayList<>();
this.pages = new ArrayList<>();
this.subsections = new LinkedHashMap<>();
}
Collection<GuiButton> 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 <b>double-page</b> 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<GuiButton> 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<String> 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, <b>excluding</b> 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 <b>single-page</b> 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<String> 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<String, String> 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<String> 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<String> 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<String, Section> 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<String, JsonElement> 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<ResourceLocation> 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();
}
}