Thursday, May 16, 2013

User Interface: Menus with scene2dui and skins - Level 4

The code from this post is available here.

Now that we're done with the AI (actually, I hope you're not done, but at least you're on your own from here on out) It was time to return to human players.  Before, they could move and attack, and that was about it, but the enemy AI could cast healing spells, shoot bow-and-arrows, etc!  Kinda one sided, don't you think?

The bulk of this update will cover a much more extensive menu system for controlling PCs.  It turns out my old post on menus has garnered a bit of attention (it even got a link on the official libgdx external tutorials page!), which is actually kind of sad because the menus we produced there SUCKED!  They had dull gray buttons, and that was about it.

For comparison, here's what we ended with last time


Compared with what we have now:


In the after shot, notice the pleasantly textured backgrounds, crisp beveled edges, and cascading design.  The first menu is for basic options, the second comes up if they click "Action", giving them the option to attack, or use abilities from either their "Knight" skills, or "Healer" skills, or to use an item, while at the same time graying out the original menu (and disabling it).  The third menu has come up by clicking on "Healer", bringing up a list of known healer spells (yes, I know, they're all the same - I was busy enough as it was!)  Also notice that the ability menu can be scrolled, though the scrollbars will gradually fade to completely transparent if they're not being used.


What you can't see is that at any time the player can hit "ESC" to close a particular menu and refocus on the previous one (if such a previous one exists).  Furthermore, once you select an ability and target a cell with it, a dialog box appears asking you to confirm that you want to do it (perhaps someday it will give some statistics about the action).


Also, once an action has been taken, or if something costs too much MP, that menu item is grayed out and disabled.


Okay, to get from the hideous, disgusting "before" menu, to the lovely, refined "after" menu, we'll begin with a look at skins.  The libgdx-users wiki has an entry on skins, which is unfortunately a little out of date.  The big thing it gets wrong any more is the distinction between "resources" and "styles".  A more up-to-date example is in the libgdx tests directory, uiskin.json.  The first two things it defines are resources - a font and a bunch of colors - after which it defines a bunch of styles.  But there's no header "distinction" between them - you just plop them all down.  If you follow the pattern in that file, you'll do fine!

Now there are really only 3 types of resources you can define - Fonts, Colors, and TintedDrawables (all of which you can read about at the official libgdx page for skins - the first link of the previous paragraph).  Other than that, you define widget styles.  But which widgets can you make styles for?  Which ones do you need?  What can you specify in the skin file?  Where do the images for it all come from?  How do you design those images?

Let's start with what you can do.  libgdx offers a bunch of widgets in the scene2dui package, each of which can be customized a little bit.  Some of them have subclasses called _____Style.  So for Label, you also need a LabelStyle, for a Button you have ButtonStyle, etc.  Not  everything has a style - for instance there is no TableStyle.  And the closest thing Dialog has to a style is WindowStyle (Dialog extends Window).  All of the _____Style's can be described in the skin, and that's it.  In my last post on this, we instantiated all those styles manually, which takes a lot of time and space.  Skins let us describe them all once, and then if we want a particular LabelStyle we can just call
skin.get("particular_label_style_I_want", LabelStyle.class);
We can define any number of LabelStyles in the Skin, and they are each referenced by a string we give it.  If you don't pass skin.get() a String value, it will assume the String you mean is "default".  To see what LabelStyle properties you can specify in the Skin, let's pull up Eclipse (or your favorite code completing software).

First instantiate a new LabelStyle variable:
LabelStyle ls = new LabelStyle()
On the next line, just start with
ls.
and see what comes up.  3 things jump out at me: a Drawable called "background", a BitmapFont called "font", and a Color called "fontColor".  These are the things you can specify in the skin.  Say we wanted to make two types of Labels - one with black text and one with gray text, with a background image called "bg.png" and font "myFont.fnt".  Here's what such a skin might look like:
{
 com.badlogic.gdx.graphics.g2d.BitmapFont: {
  default-font: { file: myFont.fnt }
 },
 com.badlogic.gdx.graphics.Color: {
  black: { a: 1, b: 0, g: 0, r: 0 },
  gray:  { a: 1, b: 0.5, g: 0.5, r: 0.5 }
 },
 com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle: {
  default: { background: bg, font: default-font, fontColor: black },
  otherone: { background: bg,
     font: default-font,
     fontColor: gray }
 }
}
Now I have two LabelStyles, one referenced by "default", the other referenced by "otherone".  If I want to instantiate a Label will this style, I can just say any of the following:
Label l1 = new Label("H", skin);
Label l2 = new Label("H", skin, "default");
Label l3 = new Label("H", skin.get(LabelStyle.class));
Label l4 = new Label("H", skin.get("default",LabelStyle.class));

Label l5 = new Label("H", skin, "otherone");
Label l6 = new Label("H", skin.get("otherone",LabelStyle.class));
All the labels here just have the text "H".  Labels 1-4 will all look the same - in fact all those constructors will ultimately call the constructor used in l4.  Labels 5-6 will have gray text, but otherwise be the same.  The skin.get() command actually returns a _____Style (of whatever type is given in the 2nd argument).

Well, this is almost good, but how does the skin file know where bg.png is?  Especially because it only calls it "bg" (without the .png extension).  And what about the font?

For all the images, we'll use the TexturePacker2 class.  A long time ago we made a class called ImagePacker which would be responsible for running libgdx's TexturePacker2.  Let's say in the GameXYZ-desktop project I have a folder called "textures", with a subfolder called "uiskin" in which I place all the images associated with the skin.  In our case, that's just bg.png, but in most cases you'll have a lot of things.  Then we call
TexturePacker2.process(settings, "textures/uiskin", "resources/uiskin", "uiskin");
which grabs everything in "textures/uiskin" and packs it into an atlas in "resources/uiskin", called "uiskin.png" and "uiskin.atlas".

Remember, this is handy for now, but in your final delivered product you don't want to run the ImagePacker to make the atlas everytime they run the program - just deliver it with the atlas already made.

In that same "resources/uiskin" directory, add the myFont.png and myFont.fnt files (made by the Hiero tool we talked about last time), as well as our uiskin.json file.  Then, when we instantiate the Skin, call
FileHandle skinFile = new FileHandle("resources/uiskin/uiskin.json");
Skin skin = new Skin(skinFile);
Then, because the atlas is also called "uiskin", our Skin will instantly know how to find "bg.png" when you just say "bg".  And because our .fnt file is also in there, if we say "file: myFont.fnt" - once again our skin can find it!

Now let's think about what widgets we need for our menu system, and how to configure them in our Skin.  Like before, our general "Menu" will consist of a ScrollPane which scrolls a Table, which has a bunch of Buttons, where each Button has a Label in it (I opted not to use TextButton for this because TextButton aligns the Label in its center, whereas I wanted it lined up along the left - and try as I might I couldn't force TextButton to line it all up like that).  So we might need Styles for each of them.  Let's look at what we can do for each - again using Eclipse's autocomplete to help us out:
ScrollPane:
  • Drawable background
  • Drawable corner
  • Drawable hScroll
  • Drawable hScrollKnob
  • Drawable vScroll
  • Drawable vScrollKnob
Button:
  • Drawable checked
  • Drawable checkedOver
  • Drawable disabled
  • Drawable down
  • Drawable over
  • float pressedOffsetX
  • float pressedOffsetY
  • float unpressedOffsetX
  • float unpressedOffsetY
  • Drawable up
Label:
  • Drawable background
  • BitmapFont font
  • Color fontColor
First, the ScrollPane.  If we set the background using the ScrollPane, it doesn't actually scroll with the contents - the table/buttons/labels will all scroll over the background, but I want the background following it.  I also don't want the user scrolling horizontally, so we can skip all that crap.  That pretty much leaves us with vScroll (what the full vertical scrollbar will look like) and vScrollKnob (what the movable piece in the middle of the bar looks like).  I just made some tiny little images for these:


The width is the width that will actually be used, but the height will be stretched to whatever size it needs (these are quite scaled up - I mean the actual file width will be used).  Now we can specify this in our uiskin.json file like so:
{
 com.badlogic.gdx.scenes.scene2d.ui.ScrollPane$ScrollPaneStyle: {
  default: { vScroll: scrollBar, vScrollKnob: scrollKnob }
 }
}
I also don't want the Buttons to specify the background.  It would be way too repeated if every button had the same background image.  Maybe when they click on it, the button can be highlighted (we can even just reuse our scrollBar.png image for this!), but in general we don't want to do background here.  I do however like the pressedOffsets, so the Button Skin will look like this:
{
 com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle: {
  default: { pressedOffsetX: 1,
    pressedOffsetY: -1,
    down: scrollBar}
 }
}
The font I've been using is irisUPC.  We want the default color to be black, and the inactive color to be gray, so for the Label we can say:
{
 com.badlogic.gdx.graphics.g2d.BitmapFont: { default-font: { file: irisUPC.fnt } },
 com.badlogic.gdx.graphics.Color: {
  black: { a: 1, b: 0, g: 0, r: 0 },
  gray:  { a: 1, b: 0.5, g: 0.5, r: 0.5 }
 },
 com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle: {
  default: { font: default-font, fontColor: black },
  inactive: { font: default-font, fontColor: gray }
 }
}
Putting it all together, our uiskin.json file will now look like this:
{
 com.badlogic.gdx.graphics.g2d.BitmapFont: { default-font: { file: irisUPC.fnt } },
 com.badlogic.gdx.graphics.Color: {
  black: { a: 1, b: 0, g: 0, r: 0 },
  gray:  { a: 1, b: 0.5, g: 0.5, r: 0.5 }
 },
 com.badlogic.gdx.scenes.scene2d.ui.ScrollPane$ScrollPaneStyle: {
  default: { vScroll: scrollBar, vScrollKnob: scrollKnob }
 },
 com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle: {
  default: { font: default-font, fontColor: black },
  inactive: { font: default-font, fontColor: gray }
 },
 com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle: {
  default: { pressedOffsetX: 1,
    pressedOffsetY: -1,
    down: scrollBar}
 }
}
The most trouble you'll probably have working with these is making sure all your brackets and commas line up right.

Of course, this is no good, we still need to have our background!  None of these options was appealing for the background - really the only appealing option is the Table, and there is no TableStyle.  For the menu texture, I used turbulent noise and an edge detector to make a 128 x 128 repeatable Texture.  (The code for this is included in the Google Code repository - check the "textures" package).


I put this image in my "textures/uiskin" directory to be packed with the rest, then in my Table I call
table.setBackground(skin.getTiledDrawable("menuTexture"));
TiledDrawable makes it repeat the background over and over to fill the Table.  If the Table is 1.5 x's as wide as the Drawable, it will Draw it once, then another half (but it will stop there).  HOWEVER, if your table is 1/2 the size as your Drawable, it will enlarge the Table to where it can draw the ENTIRE Drawable once.  In this case you'll have to be careful to keep the ScrollPane from scrolling beyond the bounds of the table you wanted.


Unfortunately, this menu ends very abruptly.  It has no edge or frame.

This makes me think of the NinePatch, which is an image where part of it (a frame) does not stretch as you resize it, but the center part (the main background) does stretch.  We don't want our inner part to stretch - we want it to tile like it's doing here, and unfortunately the NinePatch can't do that.  My workaround is kind of hackish - but who cares.  It works!

I created a class called FramedMenu which has a ScrollPane, Table, and Image.  The ScrollPane and Table are constructed like we've talked about, and the Image holds a NinePatch for the frame.  Based on the size of the ScrollPane, I will stretch the frame to cover it.  I chose to draw the Frame on top of the ScrollPane, and give the Frame a solid back border, a whitish/semi-transparent edge along the inside, and a blackish/semi-transparent shadow on the outside.  Here's my frame image:
The black pixels on the border indicate that the single transparent pixel within their "crosshairs" should be stretched.  NinePatch images don't need to be very big for just this reason.

Now FINALLY, let's look at some code.  Here's my FramedMenu.java class:
package com.blogspot.javagamexyz.gamexyz.ui;

import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.InputListener;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Button.ButtonStyle;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle;
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;

public class FramedMenu {

 private Image frame;
 private ScrollPane scrollPane;
 private Table table;
 private Skin skin;
 // max's represent the largest we want the menu getting
 private float maxHeight, maxWidth;
 
 // fontHeight and rows are used to estimate how tall our table is (frustratingly, 
 // table.getHeight() always gives me 0.0)
 private float fontHeight;
 private int rows; 
 
 // The parent menu is the one we will focus on if the user closes this one
 private FramedMenu parent;
 
 public FramedMenu(Skin skin, float preferredWidth, float preferredHeight) {
  this(skin, preferredWidth, preferredHeight, null);
 }
 
 public FramedMenu(Skin skin, float maxWidth, float maxHeight, FramedMenu parent) {
  this.skin = skin;
  
  table = new Table();
  
  this.maxHeight = maxHeight;
  this.maxWidth = maxWidth;
  
  fontHeight = skin.getFont("default-font").getCapHeight() + 14;
  rows = 0;
  this.parent = parent;
 }
 
 // Adds a button to the menu
 public void addButton(String label, ChangeListener listener, boolean active) {
  addButton(label, "", listener, active);
 }
 
 // Adds a button to the menu, with a secondary label (like MP cost) aligned to the right
 public void addButton(String label, String secondaryLabel, ChangeListener listener, boolean active) {
  LabelStyle style;
  if (active) style = skin.get(LabelStyle.class);
  else style = skin.get("inactive",LabelStyle.class);
  
  Label l = new Label(label, style);
  
  Button b = new Button(skin.get(ButtonStyle.class));
  b.addListener(listener);
  b.setDisabled(!active);

  b.add(l).left().expandX();
  b.add(new Label(secondaryLabel, style)).padRight(15f);

  table.add(b).left().padLeft(5f).expandX().fillX();
  table.row();
  
  rows++;  
 }

 // Adds the frame and scrollpane to the specified stage at the specified location.
 // Sizes the scrollpane to the (estimated) table size, up to a maximum given
 // in the constructor.
 public void addToStage(final Stage stage, float x, float y) {
  scrollPane = new ScrollPane(table, skin);
  frame = new Image(skin.getPatch("frame"));
  
  // If the user presses "ESC", close this menu and focus on the "parent"
  stage.setKeyboardFocus(scrollPane);
  scrollPane.addListener(new InputListener() {
   public boolean keyDown(InputEvent event, int keycode) {
    if (keycode == 131) { //escape
     // If this menu is invisible, don't do anything
     if (!frame.isVisible()) return false;
     
     // If there is a parent, get rid of this
     // menu and focus on it
     if (parent != null) {
      stage.setKeyboardFocus(parent.scrollPane);
      parent.enable();
      clear();
     }
     // Otherwise this must be the last one, so just clear it all
     else {
      stage.clear();
     }
    }
    return true;
   }
  });
  
  // Go ahead and add them to the stage
  stage.addActor(scrollPane);
  stage.addActor(frame);
  
  // If the table does not fill our maximum size, resize it to our
  // estimated height, and disable scrolling both x and y
  if (rows*fontHeight < maxHeight) {
   scrollPane.setScrollingDisabled(true, true);
   scrollPane.setHeight(rows*fontHeight);
  }
  
  // Otherwise, it's bigger than our maximum size, so we need to
  // enable vertical scrolling, and set the height to our max.
  else {
   scrollPane.setScrollingDisabled(true, false);
   scrollPane.setHeight(maxHeight);
  }
  
  // For now, no matter what, the width is set to maxWidth
  scrollPane.setWidth(maxWidth);
  
  table.setBackground(skin.getTiledDrawable("menuTexture"));
  
  // Move the table to the far left of the scrollPane
  table.left();
  
  // Prevent the scrollPane from scrolling (and snapping back) beyond the scroll limits
  scrollPane.setOverscroll(false, false);
  scrollPane.setFillParent(false);
  // If y is negative, center the scrollPane vertically on the stage
  if (y < 0) scrollPane.setY((stage.getHeight() - scrollPane.getHeight())/2f);
  else scrollPane.setY(y - scrollPane.getHeight());
  // If x is negative, do likewise
  if (x < 0) scrollPane.setX((stage.getWidth() - scrollPane.getWidth())/2f);
  else scrollPane.setX(x);

  // Make sure we can't touch the frame - that would make the scrollPane 
  // inaccessible
  frame.setTouchable(Touchable.disabled);

  // Now set the Frame's position and size based on the scrollPane's stuff
  frame.setX(scrollPane.getX()-1);
  frame.setY(scrollPane.getY()-3);
  frame.setWidth(maxWidth + 4);
  frame.setHeight(scrollPane.getHeight() + 4);
  
  // In case they became invisible earlier, make them visible now
  scrollPane.setVisible(true);
  frame.setVisible(true);
 }
 
 // Wipe all the buttons off, and remove widgets from stage (and reset row count)
 public void clear() {
  table.clear();
  table.setColor(1f, 1f, 1f, 1);
  if (scrollPane != null) scrollPane.remove();
  if (frame != null) frame.remove();
  rows = 0;
 }
 
 public float getY() {
  return scrollPane.getY();
 }
 
 // Make it untouchable, and gray/transparent it out
 public void disable() {
  scrollPane.setTouchable(Touchable.disabled);
  table.setColor(0.7f, 0.7f, 0.7f, 0.7f);
 }
 
 // Re-enable 
 public void enable() {
  scrollPane.setTouchable(Touchable.enabled);
  table.setColor(1,1,1,1);
 }
 
 // Make invisible or visible
 public void setVisible(boolean visible) {
  if (frame == null) return;
  frame.setVisible(visible);
  scrollPane.setVisible(visible);
 }
 
 // Let someone else know who your parent is - currently used in MenuBuilder
 public FramedMenu getParent () {
  return parent;
 }
}
Most of this stuff is self-explanatory, but let's talk about a few things.  First, the menu gets a preferred height called maxHeight in the constructor.  If I just made everything have this height, then tables with only a few buttons would have a lot of wasted space.  To get around this, I want to use table.getHeight(), but unfortunately that seems broken (always gives me a 0).  So instead I use rows (where I keep track of how many rows the table has) and fontHeight to estimate how tall the table is - but note, this is not particularly accurate right now.

Next, look at addButton() starting on line 57.  This method is used to create a menu option that has a name (like "Group Heal") and a secondary label, like the MP cost or something.  Now, Button actually extends Table, so we can use the Table alignment methods to layout our button.  Line 68 adds the first label.  .left() tells the label to move to the left of the button.  .expandX() tells the cell that the label is in to take up the ENTIRE width of the button (or at least as much as it can take - because as we'll see there will be another label in a cell that gets a teency bit).  Line 69 then adds the secondary label.  .padRight(15f) tells it to have a 15 pixel border to the right of the label.

Line 71 then adds the button to the table.  .left() again puts it to the left, .padLeft(5f) gives it a small padding on the left though, .expandX() makes the table cell that the button is being placed in take up the entire width of the table, then .fillX() tells the button widget to fill its entire cell (i.e. the entire row width).  This makes it so the button takes up the whole width of the table, so the user won't have to click directly on the text "Move" to move, but can click anywhere in that table row.

Next let's look at Line 80: addToStage().  This is where we actually instantiate the ScrollPane and Image (I wanted to do it earlier, but I ran into the weirdest dang sizing problems, and finally just gave up!)  Line 85 makes it so the ScrollPane will receive keyboard input.  Someday users may be able to press arrow keys to navigate the ScrollPane, but for now I just let them hit "escape" to close out of that menu.  I do this by adding a new InputListener() on line 86.

Big thing to notice: the InputListener stuff will look a lot like the InputProcessor stuff we did before (touchUp, touchDown, dragged, mouseMoved, etc...) but it's actually all a little bit different.  All in all it works relatively similarly though.  Here, if they hit key 131 (escape) it closes the menu (unless the menu is invisible, which happens sometimes).

Lines 114-124 are where we determine how tall to make the ScrollPane, based on the row/fontHeight stuff we talked about earlier.  On 132 we make sure the table is pushed all the way to the left within the ScrollPane.  135-142 position the ScrollPane.  It set it so that negative positions make it center within the stage.

On 146 we make the Frame untouchable.  Because the frame is drawn on top of the scrollPane, if it were touchable, it would steal all the touch events from the ScrollPane.  The frames position and size are set so that it perfectly frames the scrollPane.

Now for some helper methods.  We can clear(), which clears the table and removes the scrollPane and frame from the stage.  We can disable, which is what grays out the menus we don't want our user interacting with, or we can just make it invisible (which also makes it untouchable).

Now, we know there are really only 3 menus we will be displaying for now: Move/Action/etc..., Attack/Item/etc... and Ability1/Ability2/etc...  To handle these, I modified the CRAP out of our old MenuBuilder class.
package com.blogspot.javagamexyz.gamexyz.ui;

import com.artemis.Entity;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.Align;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.abilities.Action2;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.InputManager;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.MenuProcessor;

public class MenuBuilder {

 private InputManager inputManager;
 private MenuProcessor menuProcessor;
 private Stage stage;
 private Skin skin;
 
 private FramedMenu turnMenu;
 private FramedMenu actionMenu;
 private FramedMenu abilityMenu;
 private FramedMenu statsMenu;

 public MenuBuilder(InputManager inputManager, MenuProcessor menuProcessor, Stage stage) {
  this.inputManager = inputManager;
  this.menuProcessor = menuProcessor;
  this.stage = stage;

  FileHandle skinFile = new FileHandle("resources/uiskin/uiskin.json");
  skin = new Skin(skinFile);
  
  turnMenu = new FramedMenu(skin, 128, 128);
  actionMenu = new FramedMenu(skin, 128, 128, turnMenu);
  abilityMenu = new FramedMenu(skin, 230, 200, actionMenu);
  statsMenu = new FramedMenu(skin, 128, 128);
 }

 public void buildTurnMenu(final Entity e) {
  turnMenu.clear();
  
  // Move button
  ChangeListener moveListener = new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    menuProcessor.move();
   }
  };
  turnMenu.addButton("Move", moveListener, inputManager.canMove());
  
  // Action button
  ChangeListener actionListener = new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    buildActionMenu(e, 30, turnMenu.getY());
   }
  };
  turnMenu.addButton("Action", actionListener, inputManager.canAct());
  
  // Wait button
  ChangeListener waitListener = new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    menuProcessor.selectWait();
   }
  };
  turnMenu.addButton("Wait", waitListener, true);
  
  // Stats button
  ChangeListener statListener = new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    float w = 200f;
    float h = 100f;
    float x = (stage.getWidth() - w)/2f;
    float y = (stage.getHeight() - h)/2f;
    buildDialog("", "Status is not yet implemented", x, y, w, h, new TextButton("OK",skin));
   }
  };
  turnMenu.addButton("Status", statListener, true);
  
  turnMenu.addToStage(stage, 30, stage.getHeight() - 30);
 }
  
 public void buildActionMenu(Entity e, float x, float y) {
  actionMenu.clear();
  actionMenu.getParent().disable();
  
  // Stat based actions
  final Stats stats = e.getComponent(Stats.class);
  if (stats != null) {
   
   // Attack
   if (stats.regularAttack != null) {
    ChangeListener attack = new ChangeListener() {
     @Override
     public void changed(ChangeEvent event, Actor actor) {
      menuProcessor.action(stats.regularAttack);
     }
    };
    actionMenu.addButton(stats.regularAttack.name, attack, inputManager.canAct());
   }
   
   // Primary class
   if (stats.primaryClass != null) {
    ChangeListener primary = new ChangeListener() {
     @Override
     public void changed(ChangeEvent event, Actor actor) {
      buildAbilityMenu(stats.primaryClass.actions, stats.magic);
     }
    };
    actionMenu.addButton(stats.primaryClass.name, primary, inputManager.canAct());
   }
   
   // Secondary class
   if (stats.secondaryClass != null) {
    ChangeListener secondary = new ChangeListener() {
     @Override
     public void changed(ChangeEvent event, Actor actor) {
      buildAbilityMenu(stats.secondaryClass.actions, stats.magic);
     }
    };
    actionMenu.addButton(stats.secondaryClass.name, secondary, inputManager.canAct());
   }
  }
  
  // Item
  ChangeListener item = new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    float w = 200f;
    float h = 100f;
    float x = (stage.getWidth() - w)/2f;
    float y = (stage.getHeight() - h)/2f;
    buildDialog("", "Items have not yet been implemented", x, y, w, h, new TextButton("OK",skin));
   }
  };
  actionMenu.addButton("Item", item, inputManager.canAct());
  
  actionMenu.addToStage(stage, 30, turnMenu.getY()-5);
 }
 
 private void buildAbilityMenu(Array<Action2> actions, int mp) {
  abilityMenu.clear();
  abilityMenu.getParent().disable();
  
  // Loop through all the actions
  for (final Action2 action : actions) {
   // If they click it, process that action
   ChangeListener listener = new ChangeListener() {
    @Override
    public void changed(ChangeEvent event, Actor actor) {
     menuProcessor.action(action);
    }
   };
   // If it has an MP cost, display it as a secondary label
   // The button is active if the character has not already attacked,
   // and if the MP cost is affordable
   if (action.mpCost > 0) abilityMenu.addButton(action.name, ""+action.mpCost, listener, inputManager.canAct() && (action.mpCost < mp));
   else abilityMenu.addButton(action.name, listener, inputManager.canAct() && (action.mpCost < mp));
  }
  
  abilityMenu.addToStage(stage, -1, -1);
 }
 
 public void buildStatsMenu(Entity e) {
  statsMenu.clear();
  ChangeListener listener = new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    float w = 200f;
    float h = 100f;
    float x = (stage.getWidth() - w)/2f;
    float y = (stage.getHeight() - h)/2f;
    buildDialog("", "Status is not yet implemented", x, y, w, h, new TextButton("OK",skin));
   }
  };
  statsMenu.addButton("Status", listener, true);
  statsMenu.addToStage(stage, 30, stage.getHeight()-30);
 }
 
 public void buildDialog(String title, String message, float x, float y, float width, float height, Button... buttons) {
  FramedDialog fd = new FramedDialog(skin, title, message, width, height);
  for (Button b : buttons) {
   b.align(Align.center);
   fd.addButton(b);
  }  
  fd.addToStage(stage, x, y);
 }
 
 public TextButton getTextButton(String text, ChangeListener listener) {
  TextButton button = new TextButton(text, skin);
  if (listener != null) button.addListener(listener);
  return button;
 }
 
 public void setMenusVisible(boolean visible) {
  turnMenu.setVisible(visible);
  actionMenu.setVisible(visible);
  abilityMenu.setVisible(visible);
  statsMenu.setVisible(visible);
 }
}
Let's go ahead and decompose what this says.  First, it has FramedMenus for everything (plus one for stats, which we won't really talk about yet).  In the constructor I actually initialize the Skin.  Let's focus on the turnMenu for now.

On line 45, the first thing I do is clear out the old turnMenu.  If I don't do this, it will still have all the buttons it originally had, so when I add more, it will now just have duplicates.  Lines 48-54 are where I build the "Move" button.  The ChangeListener is called when the button is activated, and the resulting code is handled elsewhere in menuProcessor.move(), but suffice it to say, it opens up the movement range and lets the player move.

Line 54 adds it to the menu using the .addButton() method from FramedMenu.  It just goes through and does similar things for each of the other buttons.  The "Action" button, though, has its code all right here.  If you click it, it immediately builds the actionMenu at coordinates (30, turnMenu.getY()).  The coordinates used for drawing the menus base it on the top left corner, and it actually gets drawn a few pixels down, so this command just puts the actionMenu right below the turnMenu.

Line 87 actually adds the turnMenu to the stage.

Let's look down at line 148 for the abilityMenu.  At the start, we don't just clear the abilityMenu, we also disable the parent menu (the actionMenu).  We likewise did this when we built the actionMenu (disabled the turnMenu).  turnMenu doesn't have a parent, so we didn't worry about it there.  Then we just loop over all the actions, and build buttons accordingly.  If an action has an MP cost, we add a secondary label with that MP cost.  If an action costs too much MP, or the user has already acted this turn (though, in this case, we shouldn't be here anyway) the button will be disabled (we pass "false" to the addButton method).

The other "Framed" thing we make is a FramedDialog - a popup window with a row of buttons on the bottom.  When a dialog pops up, it becomes the ONLY thing you can click on, so other menus are disabled (though not grayed out, but that's okay with me).  Given a set of Buttons, lines 187-194 will build a FramedDialog, for which the code is here:
package com.blogspot.javagamexyz.gamexyz.ui;

import static com.badlogic.gdx.scenes.scene2d.actions.Actions.fadeOut;
import static com.badlogic.gdx.scenes.scene2d.actions.Actions.sequence;

import com.badlogic.gdx.math.Interpolation;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.actions.Actions;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Dialog;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.Align;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;

public class FramedDialog {

 private Dialog dialog;
 private Image frame;
 private Skin skin;
 float width, height;
 
 public FramedDialog(Skin skin, String title, String message, float width, float height) {
  this.skin = skin;
  this.width = width;
  this.height = height;
  
  dialog = new Dialog(title,skin);
  dialog.setBackground(skin.getTiledDrawable("menuTexture"));
  dialog.getContentTable().defaults().expandX().fillX();
  dialog.getButtonTable().defaults().width(50).fillX();
  
  Label label = new Label(message, skin);
  label.setAlignment(Align.center);
  label.setWrap(true);
  
  dialog.text(label);
  
  frame = new Image(skin.getPatch("frame"));
 }
 
 public void addButton(String text, ChangeListener changeListener) {
  TextButton button = new TextButton(text, skin);
  button.addListener(changeListener);
  
  button.addListener(new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    frame.addAction(sequence(fadeOut(Dialog.fadeDuration, Interpolation.fade), Actions.removeActor()));
   }
  });
  dialog.button(button);
 }
 
 public void addButton(Button button) {
  button.addListener(new ChangeListener() {
   @Override
   public void changed(ChangeEvent event, Actor actor) {
    frame.addAction(sequence(fadeOut(Dialog.fadeDuration, Interpolation.fade), Actions.removeActor()));
   }
  });
  dialog.button(button);
 }
 
 public void addToStage(Stage stage, float x, float y) {
  stage.addActor(dialog);
  stage.addActor(frame);
  
  dialog.setX(x);
  dialog.setY(y);
  dialog.setWidth(width);
  dialog.setHeight(height);
  frame.setX(x-1);
  frame.setY(y-3);
  frame.setWidth(width + 4);
  frame.setHeight(height + 4);
  
  frame.setTouchable(Touchable.disabled);
 }
}
It's similar in spirit to the menu (though quite a bit simpler!).  A Dialog is a Window with a table for content up top, and a table for Buttons on the bottom.  When any button is pressed, the Dialog will disappear (actually, it gradually fades out, so we had to match that with the frame).  In our constructor, we tell the ContentTable's cells to, by default, fill the whole thing (expandX()), and have the widgets inside those cells also fill the whole thing (fillX()).  Also by default, we want our Buttons to have a width of 50, so we set the ButtonTable default cell width to 50, and tell the buttons to fill their cells.

We also tell our Label to be centrally aligned, and enable textWrap.  When we add a Button, here I'm actually fine using TextButton (because I want the text centered anyway).  We also add a ChangeListener to all buttons so that when they are pressed, the frame will fade out with the dialog.  With that, we've got a FramedDialog.

To integrate all this into the larger context of my game, I created a few new classes:
  • InputManager
  • MenuProcesser
  • CharacterClassFactory
    • Subclass - CharacterClass
As well as modifying all the controllers, and adding a bit to the Stats component.

In the interest of NOT making this update too much longer, and because the additions are fairly minor, we'll cover these next bits in a LOT less detail.  All the code for these can be found at the Google Code repository here.

First, I moved all input controls (like the controllers, multiplexer, etc...) away from OverworldScreen and put them in InputManager.  I also got rid of the boolean handleScreen - I just handle it all the time.  It doesn't cost too much for that wasted cycle when it's empty.

I moved all the "selectedMove()" kind of stuff to MenuProcesser.  They don't immediately call stage.clear() either - instead they just make the menu's invisible.  This way, if someone is selecting where to attack, and they click out of the range, it doesn't just reset them to a blank screen, I can bring their old menus right back up.

Most of the interplay between the controllers and the menus is based on trial-and-error what do I want it doing?  For instance, everything the user tries to do gives them a "confirm" dialog box.  I know it's kind of annoying - especially regarding "Move", but it works for now.  When they choose an attack, and click on a cell to focus it on, not only does the confirm box come up, but also it highlights the cells which will be affected.  When they choose "Move" and click on a cell to move to, a "Ghost" entity will show up there, showing them exactly where they'll actually move.  You can glance through all the controllers to see how it was all done.

I will look at CharacterClassFactory though.
package com.blogspot.javagamexyz.gamexyz.abilities;

import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.ObjectMap;

public class CharacterClassFactory {
 
 private static CharacterClass knight_instance, archer_instance, wizard_instance, healer_instance;
 
 public static CharacterClass knight() {
  if (knight_instance == null) {
   knight_instance = new CharacterClass("Knight", "A badass sword user");
   knight_instance.actions.add(ActionFactory.spinAttack("Spin Attack", 6, 90));
   knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
   knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
   knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
   knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
  }
  return knight_instance;
 }
 
 public static CharacterClass archer() {
  if (archer_instance == null) {
   archer_instance = new CharacterClass("Archer", "Shoots stuff");
   archer_instance.actions.add(ActionFactory.physicalAttack("Long Range Shot", 6, 70, 5));
   archer_instance.actions.add(ActionFactory.magicAttack("Fire Arrow", 4, 0, 90, 3, 2));
  }
  return archer_instance;
 }
 
 public static CharacterClass wizard() {
  if (wizard_instance == null) {
   wizard_instance = new CharacterClass("Wizard", "Casts spells");
   wizard_instance.actions.add(ActionFactory.magicAttack("Magic Missle", 6, 3, 80, 4, 1));
   wizard_instance.actions.add(ActionFactory.magicAttack("Fireball", 10, 8, 90, 4, 2));
   wizard_instance.actions.add(ActionFactory.magicAttack("Explosion", 7, 5, 80, 4, 2));
  }
  return wizard_instance;
 }
 
 public static CharacterClass healer() {
  if (healer_instance == null) {
   healer_instance = new CharacterClass("Healer", "...heals?");
   healer_instance.actions.add(ActionFactory.cure("Heal", 5, 5, 4, 1));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
   healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
  }
  return healer_instance;
 }

 public static class CharacterClass {
  // To become this class, you need certain level proficiency in these other classes...
  public ObjectMap<CharacterClass,Integer> requirements;
  
  // Each class has a list of actions they can learn, a name, and a description
  public Array<Action2> actions;
  public String name;
  public String description;
  
  public CharacterClass(String name, String description) {
   this.name = name;
   this.description = description;
   actions = new Array<Action2>();
   requirements = new ObjectMap<CharacterClass, Integer>();
  }
 }
}
We really only ever need a single instance of a CharacterClass, so we make them all static.  The Factory just returns references to those static instances.  If the instance is null, create it, otherwise just return the already created one.  CharacterClass has an ObjectMap which can someday hold requirements - for instance, maybe you must be a level 3 knight, level 5 archer, and level 5 thief to become an Assassin.  It also has an array of Action2s that can be learned by that class (for now, they just instantly know them all).  You can see I gave Healers a LOT of the same skill, over and over again (just to test the ScrollPane design).

Lastly, in Stats, people now have a CharacterClass primaryClass and CharacterClass secondaryClass, as well as an Action2 regularAttack.  In the future, I'd like to make regularAttack be obtained from the weapon currently equipped.

Alright!  Woo-hoo!  This can actually be played now, almost like a real game.  Add some enemies with AI, a few of your own people, make some new abilities and it works alright.  Notice a few things though:
  1. The "Confirm" dialog box always pops up right in the middle.  This is annoying if it blocks the thing you're doing from visibility.
  2. If you make the screen much larger, the menus look dinky and silly.  For Android development that's not at all a problem, but for desktop it matters.  People like their fullscreen.
These are issues that I don't care to deal with now - and maybe not ever in these tutorials.  We'll see.  I'm much more interested in creating an inventory/equipment system, a character leveling system, a few more classes (plus character management like changing a class to something else), and save/load ability.  And probably some other stuff.

But for now I think we're doing alright.  Remember to check out the code!

You have gained 300 XP.  Progress to Level 5: 550/850

4 comments:

  1. This comment has been removed by the author.

    ReplyDelete
  2. Thank you so much for these series of articles! I'm learning quite a bit and I love your writing style. I hope you'll continue when you get more time.

    ReplyDelete
  3. Hi,

    I've read your post about your game on java-gaming. A shame no one replied yet (me neither ironically, but I would need to sign up one of these days).

    I'm quite impressed by your dedication. But one thing that bugs me a bit is your menu background and font styles. You have such a great minimalistic theme with your tiles but kind of ruin it using some grunchy background and futuristic font-style. Keep it the same style :)

    ReplyDelete
  4. These have been really great articles! I hope this series will continue!

    ReplyDelete