Friday, April 5, 2013

User Interface: Menus using libgdx scene2dui

UPDATE:  I have extended this tutorial to a "part 2" here in which I develop much more interesting menus, as well as discuss Skins.  For a comparison, here's a look at the Menu we finish developing here:


And here's a static glimpse of how it ends up in the next part:

I would still recommend starting here, because I will assume that you are at least familiar with how to use the widgets, ChangeListeners, etc...

I would never describe myself as an actual programmer, just somebody who knows how to program.  And I wouldn't even go that far for considering myself as an artist.  I don't know diddly squat about art.

This all came together to make developing a menu system extremely painful.  I found myself not wanting to work on it.  I found myself wishing I was doing almost anything else.  As a consequence, I did almost nothing on it, and what little I did I hated doing.

Finally I decided I just have to do the bare minimum and will move on.  If this game starts coming together later, I will freakin' deal with it then...

I ended up using the table-layout project combined with libgdx's scene2dui.  Another, much better, general tutorial for this stuff is over at Andrew Steigert's blog.  I'm going to talk about a few more features than he touches on, and show how I implemented it all in JaveGameXYZ.

First, scene2dui is a pretty cool set of tools, or widgets, that you can place to allow you to handle different kinds of user input.  They have
  • Button
    • Empty "Button" (just called Button)
    • TextButton
    • ImageButton
    • ButtonGroup
  • Image
  • TextLabel
  • TextField
  • CheckBox
  • SelectBox (drop down lists)
  • Slider
  • Window (like an actual MS Windows looking box with a title bar)
  • ScrollPane (like a window with a scrollbar on the side)
  • SplitPane (like HTML frames)
  • Touchpad (an onscreen joystick)
  • Tree (a collapsible list)
  • Dialog (a window with some place to add text, and some place to add buttons - like "OK" or whatever)
Each of these has built in convenience methods, like the ability to check when a button is pressed, click-drag to scroll along a scrollpane (including the fancy smartphone-esque feature where you touch-drag beyond the end of a scrollpane, and it goes a little bit but snaps back to where it should be as soon as you let go - you know what I mean), etc...

They also each have a "style" which can (when appropriate) set things like the font, the background image, etc.  These styles have different settings so that you can assign separate images for when a button is just sitting there, being hovered over, clicked, etc...

Furthermore, most widgets can have other widgets added to them - like you can add a label to a button, or you can add anything to a ScrollPane, etc.

You can also lay these widgets out manually, or you can use the table-layout package to make your life a little easier.  Whether you add them to a table or not, you must add them (or the table, or whatever) to a Stage.  In the libgdx scene2d language, each of these widgets is an "Actor", and actors must be placed on a "Stage".

My first thought was to build a menu with the following stucture:
ScrollPane {
   Table {
      Button
      Button
      ...
   }
}

I would assign a default size to the ScrollPane, and if the table became too big (hopefully only too long) the user would be able to scroll along it.  They could also click any menu item, which would be a button without really looking like a typical beveled edge button box, and then it would process whatever they had clicked on.

This would all fit inside a Stage held in OverworldScreen, and depending on the state of the game, I would display the stage and process user clicks.

I also wanted to be able to build a menu individually depending on which entity was selected.  For instance, some entities might not be able to move, or might have different actions available to them.  I wanted the menu to reflect specifically what actions they could perform.

I also wanted them to look maybe kind of cool - like have a nice texture background, similar to Final Fantasy Tactics.


Notice that both the nameplate and menu have a nice, subtle, textured background + beveled edges.  Tasteful.

Well what I got was a far cry from any of that, but the basic architecture was about right.

First things first, check out the updated OverworldScreen.java
Update: Blogger (or probably "I") somehow buggered the HTML for this set of code up, and consequently most of it got lost.  By now, OverworldScreen has changed a bit, and it would be a pain in the but to try to recall exactly what it had been.  I will post the full thing here - the parts you are interested in are selectedMove(), and append/set/prepend/removeInputSystems().  This is actually the OverworldScreen used for the next update, so there will be some bits that don't actually work for you yet, but those methods are the ones I wanted you to focus on now.

package com.blogspot.javagamexyz.gamexyz.screens;
package com.blogspot.javagamexyz.gamexyz.screens;

import com.artemis.Entity;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputMultiplexer;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.renderers.MapHighlighter;
import com.blogspot.javagamexyz.gamexyz.renderers.MapRenderer;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldAttackController;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldDragController;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldMovingController;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldSelectorController;
import com.blogspot.javagamexyz.gamexyz.systems.CameraMovementSystem;
import com.blogspot.javagamexyz.gamexyz.systems.DamageSystem;
import com.blogspot.javagamexyz.gamexyz.systems.FadingMessageRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.HudRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.MovementSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.TurnManagementSystem;

public class OverworldScreen extends AbstractScreen {
 
 public static GameMap gameMap;
 private OrthographicCamera hudCam;
 
 private SpriteRenderSystem spriteRenderSystem;
 private HudRenderSystem hudRenderSystem;
 private FadingMessageRenderSystem fadingMessageRenderSystem;
 private TurnManagementSystem turnManagementSystem;
 
 private MapRenderer mapRenderer;
 private MapHighlighter mapHighlighter;
 
 public int selectedEntity;
 public int activeEntity;
 public Pair activeEntityCell;
 public Array<Pair> highlightedCells;
 public boolean renderMap;
 public boolean renderMovementRange;
 public boolean renderAttackRange;
 
 public InputMultiplexer inputSystem;

 public Stage stage;
 public boolean handleStage;
 
 public int cursor;
 
 public OverworldDragController controllerDrag;
 public OverworldSelectorController controllerSelector;
 public OverworldMovingController controllerMoving;
 public OverworldAttackController controllerAttack;
 
 public Array<Integer> unitOrder;
 public boolean moved = false;
 public boolean attacked = false;
 
 public CameraMovementSystem cameraMovementSystem; 
 private boolean firstShow = true;
 
 public OverworldScreen(GameXYZ game, SpriteBatch batch, World world) {
  super(game,world,batch);

  cameraMovementSystem = new CameraMovementSystem(camera);
  activeEntityCell = new Pair(0,0);
     gameMap  = new GameMap();
     
     unitOrder = new Array<Integer>();
     setupWorld();
     setupInputSystems();
     fillWorldWithEntities();
     
     
     selectedEntity = -1;

     renderMap = true;
     renderMovementRange = false;
     renderAttackRange = false;
     
     stage = new Stage();
     handleStage = false;
     
     
 }
 
 @Override
 public void render(float delta) {
  super.render(delta);
  
  if (firstShow) {
   cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y);
   firstShow = false;
  }
  
  if (renderMap) {
   mapRenderer.render();
   spriteRenderSystem.process();
  }
  
  if (renderMovementRange) {
   mapHighlighter.render(highlightedCells,0f,0f,0.2f,0.3f);
  }
  
  fadingMessageRenderSystem.process();
  
  if (renderAttackRange) {
   mapHighlighter.render(highlightedCells,0.5f,0f,0f,0.3f);
  }
  
  if (handleStage) {
   stage.act(delta);
   stage.draw();
  }
  
  hudRenderSystem.process();
  
  cameraMovementSystem.process(delta);
 }

 @Override
 public void show() {
  
 }
 
 @Override
 public void resize(int width, int height) {
  super.resize(width, height);
  hudCam.setToOrtho(false, width, height);
  stage.setViewport(width, height, true);
 }

 @Override
 public void hide() {
  // TODO Auto-generated method stub
  
 }

 @Override
 public void pause() {
  // TODO Auto-generated method stub
  
 }

 @Override
 public void resume() {
  // TODO Auto-generated method stub
  
 }

 @Override
 public void dispose() {
  // TODO Auto-generated method stub
  world.deleteSystem(hudRenderSystem);
  world.deleteSystem(spriteRenderSystem);
  world.deleteSystem(world.getSystem(MovementSystem.class));
 }
 
 public void selectedMove() {
  if (moved) return;
  removeInputSystems(stage);
  appendInputSystems(controllerMoving);
  Entity e = world.getEntity(selectedEntity);
  Movable movable = e.getComponent(Movable.class);
  highlightedCells = gameMap.pathFinder.getReachableCells(activeEntityCell.x, activeEntityCell.y, movable);
  renderMovementRange = true;
  handleStage = false;
  stage.clear();
 }
 
 public void selectedAttack() {
  if (attacked) return;
  setInputSystems(controllerDrag,controllerAttack,controllerSelector);
  highlightedCells = MapTools.getNeighbors(activeEntityCell.x, activeEntityCell.y);
  renderAttackRange = true;
  handleStage = false;
  stage.clear();
 }
 
 public void selectedWait() {
  setInputSystems(controllerDrag, controllerSelector);
  processTurn();
  handleStage = false;
  stage.clear();
  selectedEntity = -1;
  
  moved = attacked = false;
 }
 
 public void appendInputSystems(InputProcessor... processors) {
  for (int i = 0; i < processors.length; i++) inputSystem.addProcessor(processors[i]);
 }
 
 public void setInputSystems(InputProcessor... processors) {
  inputSystem = new InputMultiplexer(processors);
  Gdx.input.setInputProcessor(inputSystem);
 }
 
 public void prependInputSystems(InputProcessor... processors) {
  InputMultiplexer newMultiplexer = new InputMultiplexer();
  
  for (int i = 0; i < processors.length; i++) {
   newMultiplexer.addProcessor(processors[i]);
  }
  
  for (InputProcessor p : inputSystem.getProcessors()) {
   newMultiplexer.addProcessor(p);
  }
  
  inputSystem = newMultiplexer;
  Gdx.input.setInputProcessor(inputSystem);
 }
 
 public void removeInputSystems(InputProcessor... processors) {
  for (int i = 0; i < processors.length; i++) {
   inputSystem.removeProcessor(processors[i]);
  }
 }
 
 private void setupWorld() {
  hudCam = new OrthographicCamera();
     
     mapRenderer = new MapRenderer(camera,batch,gameMap.map);
     mapHighlighter = new MapHighlighter(camera, batch);
     
     world.setSystem(new MovementSystem(gameMap));
     world.setSystem(new DamageSystem(gameMap));
     spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera,batch), true);
     hudRenderSystem = world.setSystem(new HudRenderSystem(hudCam, batch),true);
     fadingMessageRenderSystem = world.setSystem(new FadingMessageRenderSystem(camera,batch),true);
     turnManagementSystem = world.setSystem(new TurnManagementSystem(unitOrder), true);
     
     
     world.initialize();
     System.out.println("The world is initialized");
     
 }
 
 private void setupInputSystems() {
  controllerSelector = new OverworldSelectorController(camera,world,gameMap,this);
     controllerMoving = new OverworldMovingController(camera,world,gameMap,this);
     controllerDrag = new OverworldDragController(camera);
     controllerAttack = new OverworldAttackController(camera,world,gameMap,this);
     
     setInputSystems(controllerDrag, controllerSelector);
 }
 
 private void fillWorldWithEntities() {
  int x, y;
     for (int i=0; i<5; i++) {
      do {
       x = MathUtils.random(MapTools.width()-1);
       y = MathUtils.random(MapTools.height()-1);
      } while (gameMap.cellOccupied(x, y));
      EntityFactory.createNPC(world,x,y,gameMap).addToWorld();
     }
     
     Entity e = EntityFactory.createCursor(world);
     cursor = e.getId();
     e.addToWorld();
     
     // You have to process the world once to get all the entities loaded up with
     // the "Stats" component - I'm not sure why, but if you don't, the bag of entities
     // that turnManagementSystem gets is empty?
     world.process();
     // Running processTurn() once here initializes the unit order, and selects the first
     // entity to go
     processTurn();
 }
 
 public void processTurn() {
  turnManagementSystem.process();
  activeEntity = unitOrder.get(0);
  activeEntityCell = gameMap.getCoordinatesFor(activeEntity);
  if (activeEntityCell != null) cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y);
 }
}
It has a Stage plus a boolean handleStage.  Down in render(), if handleStage is true, it processes the stage (stage.act(delta)) and then draws it.  Also, at the end, I added a few convenience methods to try to streamline the input processor management.  appendInputSystems() adds an array of InputProcessors to the end.  setInputSystems() replaces the current input system with all the ones given.  prependInputSystems() puts an array of InputProcessors at the front of the multiplexor.

Above that, on lines 171-181, I added a method which will be called if the user actually clicks on "Move" from the menu.  We'll get to when that happens, but for now, just notice that it removes the stage from the input multiplexer, it adds the movement controller, and finds and displays all the reachable cells, then finally clears the stage out and sets handleStage to false (we want the menu to disappear now that we've made our selection).

In OverworldDefaultController, instead of assuming the user always means to "Move", we generate a menu.  To do that, we tell the Screen to handleStage, we clear the stage (in case there's some old menu hanging around), and add a new menu.
 public boolean touchUp(int screenX, int screenY, int pointer, int button) {
  
  if (dragging) {
   dragging = false;
   return true;
  }
  
  // Get the coordinates they clicked on
  Vector3 mousePosition = new Vector3(Gdx.input.getX(), Gdx.input.getY(),0);
  Pair coords = MapTools.window2world(mousePosition.x, mousePosition.y, camera);
   
  // Check the entityID of the cell they click on
  int entityId = gameMap.getEntityAt(coords.x, coords.y);
  
  // If it's an actual entity (not empty) then "select" it (unless it's already selected)  
  if ((entityId > -1) && (entityId != screen.selectedEntity)) {
   
   // Now select the current entity
   screen.selectedEntity = entityId;
   EntityFactory.createClick(world, coords.x, coords.y, 0.1f, 4f).addToWorld();
   
   screen.handleStage = true; 
   screen.stage.clear();
   screen.stage.addActor(MenuBuilder.buildMenu(screen));
   
   screen.setInputSystems(screen.stage,this);
   screen.renderMovementRange = false;
   screen.reachableCells = null;
 
   return true;
  }
Handling the menu input is easy because a Stage is a special kind of InputProcessor, so we set our multiplexor to stage and this.  That means any input will be processed by the menu first (if it can).

However, if the user click-drags off the menu, or selects any other entity they can see the game will process those clicks.  In case a user had already selected "Move", and THEN click on another entity (which is still valid), that entity will be selected and a menu will appear for them. However, because the movement range for the first entity is still being shown, we have to shut off renderMovementRange, and for good measure, clear the reachable cells we had found (if nothing else, down the line we might get a null-pointer exception if we reference the reachableCells when we shouldn't have - setting it to null might make those problems easier to detect).

This wasn't a problem before, because every time you selected an entity, it was assumed that you wanted to move, so reachableCells were recalculated and shown.  However, now, we aren't jumping to conclusions about that, so reachableCells are no longer automatically recalculated.

The MenuBuilder.buildMenu(screen) command will hopefully someday look like MenuBuilder.buildMenu(screen, entity) because different entities should have different options on their menu.  For now, I don't care.  Let's take a look at MenuBuilder:
package com.blogspot.javagamexyz.gamexyz.ui;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Button.ButtonStyle;
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.Table;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;

public class MenuBuilder {

 public static ScrollPane buildMenu(final OverworldScreen screen) {
  Texture fontTexture = new Texture(Gdx.files.internal("fonts/irisUPC.png"));
  fontTexture.setFilter(TextureFilter.Linear, TextureFilter.MipMapLinearLinear);
  TextureRegion fontRegion = new TextureRegion(fontTexture);
  BitmapFont font = new BitmapFont(Gdx.files.internal("fonts/irisUPC.fnt"), fontRegion, false);
  font.setUseIntegerPositions(false);  
  
  ButtonStyle style = new ButtonStyle();
  style.up= new TextureRegionDrawable(new TextureRegion(new Texture(Gdx.files.internal("textures/misc/button_down.png"))));
  style.unpressedOffsetX = 5f;
  style.pressedOffsetX = style.unpressedOffsetX + 1f;
  style.pressedOffsetY = -1f;
  
  
  LabelStyle lStyle = new LabelStyle();
  lStyle.font = font;
    
  Table mainTable = new Table();
  mainTable.defaults().width(80);
  
  ScrollPane scrollPane = new ScrollPane(mainTable);
  scrollPane.setFillParent(false);
  scrollPane.setX(-10);
  scrollPane.setY(screen.stage.getHeight()-250);
  
  Button b1 = new Button(style);
  b1.add(new Label("Move",lStyle));
  b1.left();
  b1.addListener(new ChangeListener() { 
   public void changed(ChangeEvent event, Actor actor) {
    screen.selectedMove();
   }
  });
  mainTable.add(b1);
  mainTable.row();
  
  Button b2 = new Button(style);
  b2.add(new Label("Attack",lStyle));
  b2.left();
  b2.addListener(new ChangeListener() { 
   public void changed(ChangeEvent event, Actor actor) {
    System.out.println("Attack");
   }
   });
  mainTable.add(b2);
  mainTable.row();
  
  // Make a bunch of filler buttons
  for (int i = 0; i < 10; i++) {
   Button b3 = new Button(style);
   b3.add(new Label("Wait",lStyle));
   b3.left();
   b3.addListener(new ChangeListener() { 
    public void changed(ChangeEvent event, Actor actor) {
     System.out.println("Wait");
    }
    });
   mainTable.add(b3);
   mainTable.row();
  }
  
  return scrollPane;
 } 
}

The first block of code is me making a BitmapFont out of a texture and font reference file.  I made this BitmapFont using the HIERO tool which you can get and learn about.  I just used a system font, but you can use a TrueTypeFont (.ttf) file instead.  It's got lots of cute options for effects - I think I made mine bold, and white.

The next block sets the style I'm going to use for my buttons.  I thought at first to go with TextButtons, but was annoyed that the text was centered on each button.  I wanted my menu layout to be left-aligned, and I couldn't find the right TextButton property to force that.  So instead I went with regular Buttons, and each button also has a Label actor in it, moved to the left of the button. So my button style doesn't have much.

I set a background image as style.up (which can't be a Texture or TextureRegion, but must be a Drawable - or maybe a NinePatch, which is a type of image where the corners maintain a fixed aspect ratio, but the middle parts stretch so you can fill however large an area you need, without losing an attractive/high-res bevel edge, or rounded corner).  For now I just made a png with 1 gray pixel.  The unpressedOffsetX gives the labels a little buffer on the left sides of words, and the pressed offsets make it easier to see what you actually pressed.

Below that a make the Label style, which is really just the font.

Then I declare the Table, and make it so it's children have a default width of 80, so all the buttons will be the same size.

Next I make the ScrollPane.  This is the main container that everything is being put into.  It won't take up the entire screen, and it's position is hardcoded (the contents show up under the HUD stuff now).

Next we make our first Button - I lovingly called it b1.  It gets the button style I had previously defined (offsets, background, etc...).  I also add a new Label to it with the text "Move" and the given style.  Ultimately I'd like to only add this option if the Entity selected actually has the Movable component.  b1.left() makes it so the label aligns to the left (which then gets pushed a little to the right by unpressedOffsetX).

The main thing is adding the changeListener().  This is better than clickListener() because it potentially allows users to fire it using methods other than clicks.  In the changeListener(), we call screen.selectedMove() - for convenience I like keeping the code on the screen instead of the menu generator.  Notice - screen had to be passed as a final variable for us to be allowed to use it inside the changeListener.  If you go to the constructor and remove the final modifier from final OverworldScreen screen it won't work.  Since we're not changing screen, that shouldn't be a problem.

Next I make a button for "Attack", then a boatload of buttons that all say "Wait".  I did that just to see how well it handled the scrolling, which is fun to play with.  It won't have a scrollbar, if we wanted that we would have to make a ScrollPaneStyle style variable, and set an image for the scrollbar, and tell it to display it, and decide if we wanted it fading out when the users mouse was gone for a few seconds, and whatever else.  But I don't Really want it.

The buildMenu() method returns the scrollPane filled with the table and buttons (which don't look like buttons), which OverworldDefaultController then adds to screen.stage and the input multiplexor.

I'd like to someday actually make a skin file to take care of all the styles more easily, and play with NinePatches.  One thing I noticed is that background images stretch, with no anti-aliasing.  Thus, if we use a background that's just a 2x2 pixel square, each pixel a different shade, it doesn't stretch up with a nice gradient effect or anything.  It just looks like a big stupid 4 color flag.  What I'd like to do to get those FFT-esque menus with their nice texture, is design a texture that will repeat-fill the menu, not stretch.  I don't really know if that is possible here.

It's a pretty hideous menu, and not at all specialized to different units' capabilities yet, but at least it's a start and I'm dying to move on!  

Woooooo... I guess.

You have gained 50 XP. Progress to Level 4: 250/700

4 comments:

  1. great series. Thanks!

    Can't wait to see your next post.

    ReplyDelete
    Replies
    1. I hope that you can reformat the article for better read
      Thanks,

      Delete
    2. Thanks wood! I'm glad you're enjoying the articles - the next one will be up very soon!

      Also, I have no idea what happened with that last post - somehow I lost a </pre> tag in the code, which messed it all up. The biggest problem is that I lost a lot of code from OverworldScreen - which is no biggy since I still have it, but I've moved beyond the state it was at here. So I put the modern stuff up, and specified the parts that were important for this article.

      Delete
  2. Thanks for article!

    I was trying to figure out how to mix my drawing and event handling with a stage so that I could get some of the features of Scene2d, but I couldn't figure out had to get started, since I didn't know how to use more than one InputProcessor.

    I'll take a look at your HUD stuff in the morning, since I think that's what I'm trying to figure out.

    ReplyDelete