Handbook rewrite part 1: Formatting and JSON-ification!

- Everything is now defined in a JSON file
- Formatting is now done dynamically and content wraps properly no matter what font is used
- Added inline hyperlinks
- Added a movable bookmark for quick access to a specific page
- Added next section and previous section buttons to flip through entire sections at once
- Added a contents button visible on every page, which navigates directly to the main contents
This commit is contained in:
Electroblob77
2019-01-05 22:38:06 +00:00
parent 78189ed862
commit 13569058ae
16 changed files with 2819 additions and 785 deletions
@@ -0,0 +1,566 @@
package electroblob.wizardry.client.gui.handbook;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import electroblob.wizardry.Wizardry;
import electroblob.wizardry.client.ClientProxy;
import electroblob.wizardry.client.DrawingUtils;
import electroblob.wizardry.client.gui.GuiButtonInvisible;
import electroblob.wizardry.client.gui.handbook.GuiButtonTurnPage.Type;
import electroblob.wizardry.constants.Constants;
import electroblob.wizardry.constants.Element;
import electroblob.wizardry.constants.Tier;
import electroblob.wizardry.registry.WizardrySounds;
import net.minecraft.client.Minecraft;
import net.minecraft.client.audio.PositionedSoundRecord;
import net.minecraft.client.audio.SoundHandler;
import net.minecraft.client.gui.GuiButton;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.multiplayer.ClientAdvancementManager;
import net.minecraft.client.renderer.GlStateManager;
import net.minecraft.client.resources.IResource;
import net.minecraft.client.resources.IResourceManager;
import net.minecraft.item.ItemStack;
import net.minecraft.util.JsonUtils;
import net.minecraft.util.NonNullList;
import net.minecraft.util.ResourceLocation;
import net.minecraftforge.event.entity.player.AdvancementEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import net.minecraftforge.fml.relauncher.ReflectionHelper;
import org.apache.commons.lang3.tuple.Pair;
import org.lwjgl.input.Keyboard;
import java.awt.*;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.util.*;
import java.util.List;
/**
* GUI class for the wizard's handbook. Like any GUI class, this is instantiated each time the book is opened. As of
* Wizardry 4.2, the handbook text is defined as a JSON file rather than a plain text file, and is loaded only on
* resource pack reload, rather than every time the book is opened. This means all the data structures (sections, images,
* etc.) are built before the GUI instance exists at all. However, since some things depend on positioning, these have to
* be initialised on GUI creation. (Previously, everything was done on GUI load)
*
* @author Electroblob
* @since Wizardry 1.0
* @see Section
* @see Contents
* @see Image
* @see CraftingRecipe
*/
@Mod.EventBusSubscriber
public class GuiWizardHandbook extends GuiScreen {
private static final ResourceLocation DEFAULT = new ResourceLocation(Wizardry.MODID, "texts/handbook_en_us.json");
static final ResourceLocation texture = new ResourceLocation(Wizardry.MODID, "textures/gui/handbook.png");
/** Global Gson instance for the handbook. */
private static final Gson gson = new Gson();
// Formatting markup
static final char FORMAT_MARKER = '#';
static final char HYPERLINK_MARKER = '@';
static final String IMAGE_TAG = "image";
static final String RECIPE_TAG = "recipe";
static final Map<String, String> FORMAT_TAGS = new HashMap<>();
// Dimension constants
// Private constants are not relevant to book elements, package-protected ones are
/** The dimensions of the rendered GUI area. */
static final int GUI_WIDTH = 288, GUI_HEIGHT = 180;
/** The dimensions of the area of a single page in which text can be drawn. */
static final int PAGE_WIDTH = 120, PAGE_HEIGHT = 140;
/** The distance of the text from the top outside corner of each page. */
static final int TEXT_INSET_X = 17, TEXT_INSET_Y = 16;
/** The distance of the buttons from the bottom outside corners of the GUI. */
private static final int BUTTON_INSET_X = 22, BUTTON_INSET_Y = 13;
/** The distance between adjacent buttons. */
private static final int BUTTON_SPACING = 20;
/** The distance of the page numbers from the bottom of the GUI. */
private static final int PAGE_NUMBER_INSET = 22;
// IDEA: Constant dimensions could be converted to JSON like the spell HUD ones
// Global variables
/**
* The <b>double-page</b> currently being viewed. Each double-page spread counts as a single page, with the inside
* of the front cover being page 0.
*/
private int currentPage = 0;
/**
* The number of <b>single</b> pages currently in the book. This is calculated on GUI load based on visible sections.
*/
private int pageCount = 1; // Starts at 1 because the first single-page is the inside of the cover
/**
* The <b>double-page</b> number where the bookmark is currently set, <b>relative to the section stored in
* {@link GuiWizardHandbook#bookmarkSection}</b>. Static because it persists when the book is closed.
*/
private static int bookmarkPage = 1;
/**
* A reference to the section in which the bookmark is currently set. Static because it persists when the book is
* closed. Storing a section means the bookmark doesn't change location when new sections are unlocked.
*/
private static Section bookmarkSection;
// Buttons
private GuiButton bookmark, next, previous, nextSection, previousSection, menu;
// Handbook content
// As a general rule, I prefer to make static final fields lowercase if they're collections that change, because even
// though the collection itself is constant, the stuff in it is not, so being lowercase highlights this difference.
/**
* A map which stores all loaded section objects, including subsections. This gets wiped on resource pack reload and
* repopulated with mappings as specified by the handbook JSON file for the current language. The keys in the map
* correspond to the keys in the sections object in that file, and are sorted in that order.
*/
static final Map<String, Section> sections = new LinkedHashMap<>();
/**
* A list which stores all loaded section objects, including subsections. This is an unmodifiable list view of the
* values in {@link GuiWizardHandbook#sections}, sorted in the same (page number) order. This exists only to allow
* sections to be accessed by ordinal index for the various navigation buttons, hence why it is private.
*/
private static List<Section> sectionList;
/**
* A map which stores all loaded contents objects. This gets wiped on resource pack reload and repopulated with
* mappings as specified by the handbook JSON file for the current language. The keys in the map correspond to the
* id strings for the contents objects in that file. This map is not sorted.
*/
static final Map<String, Contents> contentsList = new HashMap<>();
/**
* A map which stores all loaded hex colour values. This gets wiped on resource pack reload and repopulated with
* mappings as specified by the handbook JSON file for the current language. The keys in the map correspond to the
* keys in the colours object in that file. This map is not sorted.
*/
static final Map<String, Integer> colours = new HashMap<>();
/**
* A map which stores all loaded image objects. This gets wiped on resource pack reload and repopulated with
* mappings as specified by the handbook JSON file for the current language. The keys in the map correspond to the
* keys in the images object in that file. This map is not sorted.
*/
static final Map<String, Image> images = new HashMap<>();
/**
* A map which stores all loaded crafting recipe objects. This gets wiped on resource pack reload and repopulated
* with mappings as specified by the handbook JSON file for the current language. The keys in the map correspond to
* the keys in the recipes object in that file. This map is not sorted.
*/
static final Map<String, CraftingRecipe> recipes = new HashMap<>();
static String rulerText = "--------------------"; // Assigned here as a fallback
private static final List<Pair<ItemStack, NonNullList<NonNullList<ItemStack>>>> RECIPES = new ArrayList<>();
// Reflection
static final Field ADVANCEMENT_TO_PROGRESS;
static {
// TODO: Obf field name
ADVANCEMENT_TO_PROGRESS = ReflectionHelper.findField(ClientAdvancementManager.class, "advancementToProgress");
}
/**
* Adds a format tag to the handbook. All occurrences of the given tag string preceded by a # will be replaced with
* the result of the given value string on GUI load. The value string, therefore, can be anything that should be
* input dynamically, as long as it does not change while the GUI is open. Examples include wizardry's version,
* the various element colours and the keys assigned to wizardry's controls.
* @param tag The tag string, as defined in the handbook JSON file, excluding the # character. Cannot include spaces.
* @param value The string to replace occurrences of the given format tag with. Can include spaces but not the # character.
*/
public static void addFormatTag(String tag, String value){
FORMAT_TAGS.put(tag, value);
}
private static void initFormatTags(){
addFormatTag("next_spell_key", ClientProxy.NEXT_SPELL.getDisplayName());
addFormatTag("previous_spell_key", ClientProxy.PREVIOUS_SPELL.getDisplayName());
addFormatTag("mana_per_crystal_minus_30", "" + (Constants.MANA_PER_CRYSTAL - 30));
addFormatTag("mana_per_crystal", "" + Constants.MANA_PER_CRYSTAL);
addFormatTag("novice_max_charge", "" + Tier.BASIC.maxCharge);
addFormatTag("apprentice_max_charge", "" + Tier.APPRENTICE.maxCharge);
addFormatTag("advanced_max_charge", "" + Tier.ADVANCED.maxCharge);
addFormatTag("master_max_charge", "" + Tier.MASTER.maxCharge);
addFormatTag("version", Wizardry.VERSION);
addFormatTag("mcversion", Minecraft.getMinecraft().getVersion());
addFormatTag("colour_novice", "\u00A77");
addFormatTag("colour_apprentice", Tier.APPRENTICE.getFormattingCode());
addFormatTag("colour_advanced", Tier.ADVANCED.getFormattingCode());
addFormatTag("colour_master", Tier.MASTER.getFormattingCode());
addFormatTag("colour_fire", Element.FIRE.getFormattingCode());
addFormatTag("colour_ice", Element.ICE.getFormattingCode());
addFormatTag("colour_lightning", Element.LIGHTNING.getFormattingCode());
addFormatTag("colour_necromancy", Element.NECROMANCY.getFormattingCode());
addFormatTag("colour_earth", Element.EARTH.getFormattingCode());
addFormatTag("colour_sorcery", Element.SORCERY.getFormattingCode());
addFormatTag("colour_healing", Element.HEALING.getFormattingCode());
addFormatTag("colour_reset", "\u00A70");
}
// Helper methods
/**
* Converts the given single page index to a double-page index. Inverse of
* {@link GuiWizardHandbook#doubleToSinglePage(int, boolean)}.
*
* @param singlePageIndex The single-page index, which is the same as the page numbers actually displayed.
* @return The corresponding double-page index.
*/
static int singleToDoublePage(int singlePageIndex){
// Yes, this is trivial, but if I ever change the numbering it'll be useful. It's also more descriptive.
return singlePageIndex / 2;
}
/**
* Converts the given double-page index to a single-page index. Inverse of
* {@link GuiWizardHandbook#singleToDoublePage(int)}.
*
* @param doublePageIndex The double-page index, as stored in {@link GuiWizardHandbook#currentPage}.
* @param rightHandPage True to return the page on the right (1 greater), false for the left-hand page.
* @return The corresponding single-page index.
*/
static int doubleToSinglePage(int doublePageIndex, boolean rightHandPage){
return rightHandPage ? doublePageIndex * 2 + 1 : doublePageIndex * 2;
}
/**
* Returns whether the given page index refers to a right-hand page or a left-hand page.
*
* @param page The single-page index, which is the same as the page number actually displayed.
* @return True if the given page index refers to a right-hand page, false if it is a left-hand page.
*/
static boolean isRightPage(int page){
return page % 2 == 1;
}
// Drawing
@Override
public void drawScreen(int mouseX, int mouseY, float partialTicks){
int left = this.width / 2 - GUI_WIDTH / 2;
int top = this.height / 2 - GUI_HEIGHT / 2;
Minecraft.getMinecraft().renderEngine.bindTexture(texture);
// Main background
DrawingUtils.drawTexturedRect(left, top, 0, 0, GUI_WIDTH, GUI_HEIGHT, 512, 256);
// First page background
if(currentPage == 0){
DrawingUtils.drawTexturedRect(left, top, 368, 0, GUI_WIDTH / 2, GUI_HEIGHT, 512, 256);
previous.visible = false;
previousSection.visible = false; // Not worth testing if we're in the first section every frame
menu.visible = false;
}else{
previous.visible = true;
previousSection.visible = true;
menu.visible = true;
}
// Last page background
if(currentPage == singleToDoublePage(pageCount)){
DrawingUtils.drawTexturedFlippedRect(left + GUI_WIDTH / 2, top, 368, 0, GUI_WIDTH / 2, GUI_HEIGHT, 512, 256, true, false);
next.visible = false;
nextSection.visible = false;
}else{
next.visible = true;
nextSection.visible = true;
}
// Page numbers
if(currentPage > 0){
String pageNumber = "" + doubleToSinglePage(currentPage, false);
this.fontRenderer.drawString(pageNumber, left + TEXT_INSET_X + PAGE_WIDTH / 2
- fontRenderer.getStringWidth(pageNumber)/2, top + GUI_HEIGHT - PAGE_NUMBER_INSET, DrawingUtils.BLACK);
}
if(currentPage < singleToDoublePage(pageCount)){
String pageNumber = "" + doubleToSinglePage(currentPage, true);
this.fontRenderer.drawString(pageNumber, left + GUI_WIDTH - TEXT_INSET_X - PAGE_WIDTH / 2
- fontRenderer.getStringWidth(pageNumber)/2, top + GUI_HEIGHT - PAGE_NUMBER_INSET, DrawingUtils.BLACK);
}
// Main content
images.values().forEach(i -> i.draw(fontRenderer, currentPage, left, top));
contentsList.values().forEach(c -> c.draw(fontRenderer, currentPage, left, top));
sections.values().forEach(s -> s.draw(fontRenderer, currentPage, left, top));
recipes.values().forEach(r -> r.draw(fontRenderer, itemRender, currentPage, left, top));
// Buttons
super.drawScreen(mouseX, mouseY, partialTicks);
// Bookmark
GlStateManager.color(1, 1, 1, 1);
Minecraft.getMinecraft().renderEngine.bindTexture(texture);
if(currentPage == singleToDoublePage(bookmarkSection.startPage) + bookmarkPage){
bookmark.visible = false;
DrawingUtils.drawTexturedRect(left + 138, top, 299, 0, 11, 191, 512, 256);
}else{
bookmark.visible = true;
bookmark.x = left + (currentPage > singleToDoublePage(bookmarkSection.startPage) + bookmarkPage ? 130 : 147);
DrawingUtils.drawTexturedRect(bookmark.x, top,
bookmark.isMouseOver() ? 310 : 288, 0, 11, 191, 512, 256);
}
// Recipe tooltips
recipes.values().forEach(r -> r.drawTooltips(this, fontRenderer, itemRender, currentPage, left, top, mouseX, mouseY));
}
// GUI Initialisation / Close
@Override
public void onResize(Minecraft minecraft, int width, int height){
initGui();
}
@Override
public void onGuiClosed(){
super.onGuiClosed();
Keyboard.enableRepeatEvents(false);
}
@Override
public void initGui(){
super.initGui();
Keyboard.enableRepeatEvents(true);
initFormatTags();
final int left = this.width / 2 - GUI_WIDTH / 2;
final int top = this.height / 2 - GUI_HEIGHT / 2;
recipes.values().forEach(CraftingRecipe::load);
int nextButtonId = 0;
this.buttonList.clear();
this.buttonList.add(next = new GuiButtonTurnPage(nextButtonId++, left + GUI_WIDTH - BUTTON_INSET_X - GuiButtonTurnPage.WIDTH,
top + GUI_HEIGHT - BUTTON_INSET_Y - GuiButtonTurnPage.HEIGHT, Type.NEXT_PAGE));
this.buttonList.add(previous = new GuiButtonTurnPage(nextButtonId++, left + BUTTON_INSET_X,
top + GUI_HEIGHT - BUTTON_INSET_Y - GuiButtonTurnPage.HEIGHT, Type.PREVIOUS_PAGE));
this.buttonList.add(nextSection = new GuiButtonTurnPage(nextButtonId++, left + GUI_WIDTH - BUTTON_INSET_X - GuiButtonTurnPage.WIDTH - BUTTON_SPACING,
top + GUI_HEIGHT - BUTTON_INSET_Y - GuiButtonTurnPage.HEIGHT, Type.NEXT_SECTION));
this.buttonList.add(previousSection = new GuiButtonTurnPage(nextButtonId++, left + BUTTON_INSET_X + BUTTON_SPACING,
top + GUI_HEIGHT - BUTTON_INSET_Y - GuiButtonTurnPage.HEIGHT, Type.PREVIOUS_SECTION));
this.buttonList.add(menu = new GuiButtonTurnPage(nextButtonId++, left + GUI_WIDTH/2 - 28,
top + GUI_HEIGHT - BUTTON_INSET_Y - GuiButtonTurnPage.HEIGHT, Type.CONTENTS));
this.buttonList.add(bookmark = new GuiButtonInvisible(nextButtonId++, left + 130, top + 172, 11, 19) {
@Override
public void playPressSound(SoundHandler soundHandler){
soundHandler.playSound(PositionedSoundRecord.getMasterRecord(WizardrySounds.MISC_PAGE_TURN, 1));
}
});
pageCount = 1;
// Clears instances of all images and recipes
images.values().forEach(Image::clearInstances);
recipes.values().forEach(CraftingRecipe::clearInstances);
// Formats all the unlocked sections in order
for(Section section : sections.values()){
if(section.isUnlocked()){
pageCount = section.format(this.fontRenderer, pageCount, left, top);
buttonList.addAll(section.getButtons());
}
}
contentsList.values().forEach(c -> buttonList.addAll(c.getButtons()));
this.mc.getSoundHandler().playSound(PositionedSoundRecord.getMasterRecord(WizardrySounds.MISC_BOOK_OPEN, 1));
}
// JSON Parsing / Data Construction
/**
* Called from preInit in the main mod class (via the proxies) to initialise the handbook (parses the JSON file
* and constructs the relevant data structures), and again on each resource reload (changing the language triggers
* a resource reload).
*/
public static void loadHandbookFile(IResourceManager manager){
IResource handbookFile = getHandbookResource(manager);
if(handbookFile != null){
// Wipes all the maps before repopulating them
images.clear();
sections.clear();
contentsList.clear();
colours.clear();
bookmarkSection = null; // Also need to wipe the reference to the old bookmarked section
BufferedReader reader = new BufferedReader(new InputStreamReader(handbookFile.getInputStream()));
JsonElement je = gson.fromJson(reader, JsonElement.class);
JsonObject json = je.getAsJsonObject();
rulerText = JsonUtils.getString(json, "ruler_text");
JsonUtils.getJsonObject(json, "colours").entrySet().forEach(e -> colours.put(e.getKey(),
Color.decode(e.getValue().getAsString()).getRGB()));
// Repopulates the remaining maps
Image.populate(images, json);
CraftingRecipe.populate(recipes, json);
Section.populate(sections, json);
sectionList = Collections.unmodifiableList(new ArrayList<>(sections.values()));
if(sections.isEmpty()){
Wizardry.logger.warn("Handbook has no sections! Aborting loading...");
return;
}
// Attempts to find a suitable place to put the bookmark to start with
bookmarkSection = sections.get("introduction");
if(bookmarkSection == null) bookmarkSection = sectionList.get(0);
}
}
/**
* Retrieves the handbook JSON file for the current language and returns its IResource object. If a handbook file
* cannot be found for the current language, a message is printed to the console and the method attempts to retrieve
* the default file instead (English-US). If this file cannot be found, the resulting error is printed to the
* console and the method returns null.
*
* @param manager The resource manager instance to use.
* @return The handbook JSON file, as an IResource, or null if it was not found.
*/
private static IResource getHandbookResource(IResourceManager manager){
IResource handbookFile = null;
try{
handbookFile = manager.getResource(new ResourceLocation(Wizardry.MODID, "texts/handbook_"
+ Minecraft.getMinecraft().getLanguageManager().getCurrentLanguage().getLanguageCode() + ".json"));
}catch(IOException e){
Wizardry.logger.info("Wizard handbook JSON file missing for the current language (" + Minecraft.getMinecraft()
.getLanguageManager().getCurrentLanguage() + "). Using default (English-US) instead.");
try{
handbookFile = manager.getResource(DEFAULT);
}catch(IOException x){
Wizardry.logger.error("Couldn't find file: " + DEFAULT + ". The file may be missing; please try re-downloading and reinstalling Wizardry.", x);
}
}
return handbookFile;
}
// Controls
@Override
protected void actionPerformed(GuiButton button){
if(button.enabled){
if(button == next){
if(currentPage < singleToDoublePage(pageCount)) currentPage++;
}else if(button == previous){
if(currentPage > 0) currentPage--;
}else if(button == nextSection || button == previousSection){
Section currentSection = null;
for(Section section : sections.values()){
// We always want this button to do something, and taking the right-hand page means it always does
if(section.containsPage(doubleToSinglePage(currentPage, true))){
currentSection = section;
break;
}
}
if(currentSection != null){
int index = sectionList.indexOf(currentSection);
if(button == nextSection && index + 1 < sections.size()){
currentPage = singleToDoublePage(sectionList.get(index + 1).startPage);
}else if(index > 0){
currentPage = singleToDoublePage(sectionList.get(index - 1).startPage);
}
}
}else if(button == menu){
currentPage = singleToDoublePage(sections.get("main_contents").startPage);
}else if(button == bookmark && bookmarkSection != null){
currentPage = singleToDoublePage(bookmarkSection.startPage) + bookmarkPage;
}else{
if(button instanceof GuiButtonHyperlink.Internal){
currentPage = singleToDoublePage(((GuiButtonHyperlink.Internal)button).target.startPage);
}else if(button instanceof GuiButtonHyperlink.External){
this.handleComponentClick(((GuiButtonHyperlink.External)button).link);
}
}
}
}
@Override
protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException{
if(mouseButton == 1){
// Right-clicking of bookmark
if(bookmark.mousePressed(this.mc, mouseX, mouseY)){
this.selectedButton = bookmark;
for(Section section : sections.values()){
// The bookmark is assumed to bookmark the left-hand page
if(section.containsPage(doubleToSinglePage(bookmarkPage, false))) bookmarkSection = section;
}
bookmarkPage = currentPage - singleToDoublePage(bookmarkSection.startPage);
}
}else{
super.mouseClicked(mouseX, mouseY, mouseButton);
}
}
// Overridden to make it public
@Override
public void renderToolTip(ItemStack stack, int x, int y){
super.renderToolTip(stack, x, y);
}
@SubscribeEvent
public static void onAdvancementEvent(AdvancementEvent event){
sections.values().forEach(s -> s.onAdvancement(event.getEntityPlayer(), event.getAdvancement()));
}
}