FMF:Custom Menu 3

From OHRRPGCE-Wiki
Jump to: navigation, search
Mobile-phone.png
This article is about the OHRRPGCE FMF project, which is an alternate implementation of the OHRRPGCE for Java mobile phones. Technical implementation details discussed here should not be confused with those of the RPG format


Our custom menu looks pretty good, but it's possible to add that "finishing touch" to make it have just the right amount of professionalism. Visual optimizations like this can often be described in very simple terms, and ours is no exception:

  • The hand should point to parts of the cube, not to the heroes' names.
  • Specifically, the hand should point to the green part for Bob, orange for James, and purple for Dusty.
  • Let's also show the hero's downward-facing walkabout, in something of a "submenu" fashion (see screenshot).
  • For added fluidity, the current sub-menu should "slide" into place.

Here's our mockup:

Fmf menu3 mockup.PNG

Why is this such an interesting challenge?

  • The name/pic Slices have different heights, and make layout tricky.
  • Moving a cursor to "parts" of the cube requires either a messy Focus listener or the (rather hardcore) addition of a custom Slice type.
  • How do we "slide in" a Slice?

For your viewing pleasure, this tutorial is tied to revision 88 of the OHRRPGCE FMF source --it'll compile (almost) right out of the box.


Step One: Our Custom Slice[edit]

Whenever you find yourself saying "gee, I wish a Something Slice existed for such and such a purpose", you might consider just implementing that Slice yourself. With the exception of resizing, the Slicing Interface is organize to allow simple development of new components. The best place to start is the constructor. Although we could extend the Image Slice class, I've chosen to start from scratch and build directly off MenuSlice.jave:

 public class CubeSlice extends MenuSlice {
   private ImageAdapter cubeImg;
   private char lastCubeSide;
   private char currCubeSide;
 
   public CubeSlice(MenuFormatArgs mFormat, ImageAdapter pngImage) {
     super(mFormat);
     this.cubeImg = pngImage;
     this.currCubeSide = 'L';
   }
 
   //Accessor for currCubeSide. Used later
   public char getCubeSide() {
     return currCubeSide;
   }
 }

Calling super(mFormat) is usually the way to go, unless you need to modify some of the Menu Format Args on the fly. Even then, it's better to just do this after; see ListSlice.java for an example. Notice that all we do is save the image for later, and scribble down that we start on the "L"eft side of the cube.

At this point, it's very important to consider the question "What does a MINIMUM width/height for my Slice imply?" In some cases, it's an error. In our case, though, it's easy: our minimum width/height is the same as that of our image. So, we now over-ride the following two placeholder methods:

 protected int calcMinWidth() {
   if (cubeImg!=null) {
     return cubeImg.getWidth();
   } else {
     return super.calcMinWidth();
   }
 }
 protected int calcMinHeight() {
   if (cubeImg!=null) {
     return cubeImg.getHeight();
   } else {
     return super.calcMinHeight();
   }
 }

These methods return -1 ("error") by default; we over-ride them to return our image's width, if it exists. Note that we do not have to figure in borders or border padding; MenuSlice.java will do that for us in doLayout().

Now that that's done, let's consider the second-most-important question: "Do I need to paint anything special for this Slice?". The answer should be obvious: we need to paint our image! There's many methods you could override to accomplish this, but I prefer drawPixelBuffer(), since our image logically takes its place. Also, this ensures that it's drawn under the borders, which is sometimes necessary. Here we go:

 protected void drawPixelBuffer(int atX, int atY) {
   if (cubeImg!=null) {
     //Draw our image centered...
     int tlX = this.getPosX() + (this.getWidth()/2-cubeImg.getWidth()/2);
     int tlY = this.getPosY() + (this.getHeight()/2-cubeImg.getHeight()/2);
 
     GraphicsAdapter.drawImage(cubeImg, tlX, tlY);
   }
 }

The reason we center our image is so that we don't have to bother figuring out where the "logical" top-left X/Y coordinates are (the borders and border padding affect this). And, it makes sense for another logical reason: most users would expect it. Remember, our width/height can be fixed instead of MINIMUM, and our Slice should behave in a way that caters to the expectations of the users.

There's two more functions to over-ride. The first one is consumeInput(). When the user presses a key ("LEFT"), the current menu slice calls consumeInput() to see if this key press was handled internally by the component. If this function returns false, then the slice tries to "moveTo()" the slice left of it. FlatList.java and List.java have good examples of how to over-ride this method. In our case, we only consume if we "move" around the cube:

 public boolean consumeInput(int direction) {
   if (direction==MenuSlice.CONNECT_LEFT && (currCubeSide=='R' || currCubeSide=='T'))
     currCubeSide = 'L';
   else if (direction==MenuSlice.CONNECT_RIGHT && (currCubeSide=='L' || currCubeSide=='T'))
     currCubeSide = 'R';
   else if (direction==MenuSlice.CONNECT_TOP && (currCubeSide=='L' || currCubeSide=='R')) {
     lastCubeSide = currCubeSide;
     currCubeSide = 'T';
   } else if (direction==MenuSlice.CONNECT_BOTTOM && currCubeSide=='T')
     currCubeSide = lastCubeSide;
   else
     return false;
 
   return true;
 }

Make sense? We use "lastCubeSide" to store the L/R sides of the cube if we go to the "top"... it makes sense that pressing UP and DOWN in series when on the right side should never bring us back to the left. The next function to over-ride is a bit tricky to understand: getActiveRectangle() has unspecified internal functionality, but is usually used to handle highlights. We return the area we want our hand to point to.

 public int[] getActiveRectangle() {
   if (currCubeSide == 'L') {
     return new int[]{this.getPosX()+17, this.getPosY()+37, 21, 19};
   } else if (currCubeSide == 'R') {
     return new int[]{this.getPosX()+40, this.getPosY()+38, 23, 20};
   } else if (currCubeSide == 'T') {
     return new int[]{this.getPosX()+28, this.getPosY()+20, 24, 13};
   } else {
     //This should never happen, but keep it just for consistency
     return super.getActiveRectangle();
   }
 }

Note that we just hard-coded in the different sub-sections of our Slice; the proper way to do this is to take in an int[3][] in the constructor and reference them individually. But for our simple menu, this is easier to grasp.

We're done! Except... you may have noticed that the highlight is normally applied when the focus is gained, and hitting left/right won't change the focus if it just sets the current cube side. We could fire off a focus gained listener.... but that's kind of misleading --the user may have already hooked up several focus listeners to this Menu Slice. Instead, let's make our own listner: a "SubSelectionChangedListener". We can do this by adding a private variable and a setter, like so:

 //Add a private variable to store this, and its setter
 private Action subSelectionChangedListener;
 public void setSubSelectionChangedListener(Action newListener) {
   this.subSelectionChangedListener = newListener;
 }

You might ask why we used "set" instead of "add", like we did for Focus Gained Listeners. We just wanted to keep things small; it's unlikely that the user would want to hook up more than one sub-selection listener. Anyways, let's modify consumeInput(). Right before the line "return true;" add:

 if (this.subSelectionChangedListener != null) {
   this.subSelectionChangedListener.perform(this);
 }

Now, we can create our Menu Slice, and attach our listener. But we'll save that for the next section.


Step 1.5: Reflect[edit]

It's important to consider what you just did, and because this lesson is so important, I'm going to put it in big text:

Cube Slice described itself to the Menu Engine

Another way to handle the special "sub-section" behavior of Cube Slice is to change the Menu Engine to detect when it's sent a key-press to a Cube Slice and refrain from moving on that key-press. This might also require you to modify MenuSlice.java. While not wrong per se, the benefit to doing it our way is that everything you need to know about a Cube Slice is contained within one file, CubeSlice.java. As your menuing system gets larger, this is a big win for maintainability.


Step Two: Static Menu Layout[edit]

The next logical step is to go through the mundane task of laying out our menu. In this case, we have a few surprises. Here's the entirety of our layout code:

 //Large overlay
 MenuFormatArgs mfClear = new MenuFormatArgs();
 mfClear.bgColor = 0x333333;
 mfClear.fillType = MenuSlice.FILL_SOLID;
 mfClear.borderColors = new int[]{};
 mfClear.widthHint = width;
 mfClear.heightHint = height;
 mfClear.borderPadding = 10;
 MenuSlice largeClearBox = new MenuSlice(mfClear);
 
 //Intialize our cursor
 String workingDir = Meta.pathToGameFolder;
 MenuFormatArgs mfCursor = new MenuFormatArgs();
 mfCursor.widthHint = MenuFormatArgs.WIDTH_MINIMUM;
 mfCursor.heightHint = MenuFormatArgs.HEIGHT_MINIMUM;
 mfCursor.borderColors = new int[]{};
 mfCursor.fillType = MenuSlice.FILL_NONE;
 ImageAdapter cursorImg = null;
 try {
   cursorImg = adaptGen.createImageAdapter(workingDir+"hand.png");
 } catch (IOException ex) {
   cursorImg = adaptGen.createBlankImage(10, 10);
 }
 MetaMenu.currCursor =  new ImageSlice(mfCursor, cursorImg);
 MetaMenu.currCursor.doLayout();
 
 //Image for our cube
 ImageAdapter box1Pic = null;
 try {
   box1Pic = adaptGen.createImageAdapter(workingDir + "box.png");
 } catch (IOException ex) {
   box1Pic = adaptGen.createBlankImage(10, 10);
 }
 
 //Our new slice initializes like any other
 MenuFormatArgs mf = new MenuFormatArgs();
 mf.fillType = MenuSlice.FILL_NONE;
 mf.borderColors = new int[]{};
 mf.widthHint = MenuFormatArgs.WIDTH_MINIMUM;
 mf.heightHint = MenuFormatArgs.HEIGHT_MINIMUM;
 CubeSlice blueGraphic = new CubeSlice(mf, box1Pic);
 
 //Our cube will share the same highlight and focus listeners
 Action makeHighlightAction = new Action() {
   public boolean perform(Object caller) {
    //Put the cursor somewhere near the middle of this slice's sub-component
     MenuSlice calledBy = (MenuSlice)caller;
     int[] rectangle = calledBy.getActiveRectangle();
     MetaMenu.currCursor.forceToLocation(
     rectangle[0]+rectangle[2]/2-MetaMenu.currCursor.getWidth(), 
     rectangle[1]+rectangle[3]/2-MetaMenu.currCursor.getHeight()/2);    			
     return true; //Return value doesn't matter.
   }
 };
 blueGraphic.setSubSelectionChangedListener(makeHighlightAction);
 blueGraphic.addFocusGainedListener(makeHighlightAction);
   	
 //Clip within subMenuSlice's bounds
 /*blueGraphic.addFocusGainedListener(
 new Action() {
   public boolean perform(Object caller) {
     MenuSlice rightConnect = ((MenuSlice)caller).getConnect(MenuSlice.CONNECT_RIGHT, MenuSlice.CFLAG_PAINT);
     rightConnect.setClip(rightConnect.getPosX(), rightConnect.getPosY(), rightConnect.getWidth(), rightConnect.getHeight());
     return false;
   }	
 });*/
   
 //Let's make a box for our "sub" menu
 mf.bgColor = 0xFFC20E;
 mf.borderColors = new int[]{0x333333, 0xFF7E00};
 mf.fillType = MenuSlice.FILL_SOLID;
 mf.fromAnchor = GraphicsAdapter.TOP|GraphicsAdapter.RIGHT;
 mf.toAnchor = GraphicsAdapter.TOP|GraphicsAdapter.LEFT;
 mf.xHint = 10;
 mf.widthHint = 100;
 mf.heightHint = 150;
 mf.borderPadding = 0;
 MenuSlice subMenuSlice = new MenuSlice(mf);
 
 //Our label & pic will be transparent, and laid out within THIS box:
 mf.borderColors = new int[]{0xBD8A00, 0xFF7E00};
 mf.fromAnchor = GraphicsAdapter.TOP|GraphicsAdapter.LEFT;
 mf.xHint = -2;
 mf.yHint = -2;
 mf.widthHint = MenuFormatArgs.WIDTH_MINIMUM;
 mf.heightHint = MenuFormatArgs.HEIGHT_MINIMUM;
 mf.borderPadding = 3;
 MenuSlice captionSlice = new MenuSlice(mf);
 
 //int[] data for James
 Hero james = rpg.getHero(1);
 int[] jamesWalk = james.getWalkabout().spData[4];
 int jamesWalkPalette = james.walkaboutPaletteID;
 int walkaboutWidth = 20;
 
 //James's Pic
 mf.fillType = MenuSlice.FILL_NONE;
 mf.borderColors = new int[0];
 mf.borderPadding = 0;
 mf.xHint = 0;
 mf.yHint = 0;
 ImageSlice jamesPic = new ImageSlice(mf, jamesWalk, jamesWalkPalette, rpg, walkaboutWidth);
 
 //James's Name
 mf.xHint = 3;
 mf.fromAnchor = GraphicsAdapter.VCENTER|GraphicsAdapter.RIGHT;
 mf.toAnchor = GraphicsAdapter.VCENTER|GraphicsAdapter.LEFT;
 TextSlice jamesText = new TextSlice(mf, "James", rpg.font, true, true, false);
 
 //Connect & set Children
 largeClearBox.setTopLeftChild(blueGraphic);    	
 blueGraphic.connect(subMenuSlice, MenuSlice.CONNECT_RIGHT, MenuSlice.CFLAG_PAINT);
 subMenuSlice.setTopLeftChild(captionSlice);
 captionSlice.setTopLeftChild(jamesPic);
 jamesPic.connect(jamesText, MenuSlice.CONNECT_RIGHT, MenuSlice.CFLAG_PAINT);
 
 //Some bookkeeping
 MetaMenu.topLeftMI = largeClearBox;

The notable surprises:

  • Why do we give the sub-menu an outer border of "0x333333"? Basically, to make our text/pic of James look shadowed, we give it an x/y hint of -2. That way, the shadow will only show on the bottom and right edges (like a real shadow) instead of all around. We can "hide" the top/left shadow under the border of the sub-menu box. (Recall that children are drawn before the borders for that slice.) However, our caption slice doesn't clip, so 1px of border will show. We chose to hack the solution by adding an additional gray line to our sub-menu slice. Look a few lines up to find a call to "blueGraphic.addFocusGainedListener()" which is commented out; this is the "correct" way to do it with clipping.
  • Note how James's pic is actually an integer array. This is the second way to call Image Slice's constructor, and it works well with data gleaned directly from the RPG. FYI, if you use this code in your game, you'll want to avoid hard-coding any data (like James's name).
  • Note how, in order to get a proper cursor layout, we need to add our cursor positioning code as both a Focus Listener and a Sub Section Changed Listener.
  • We didn't show it earlier, but createImageAdapter() might fail. In this case, we give our Slice a "blank" image (possibly, you could replace the blank image with, say, one that's a red X or whatever. At the moment, a LiteException is thrown, so the point is moot.)

Here's a screenshot of our system in action.

Fmf menu3proto live.PNG


Step Three: Intro to Animations[edit]

You may have noticed that our sub-menu is a bit longer than it was in our design sketch. As a result, I've decided to do the animation from top to bottom, instead of from left to right. (Remember to constantly refactor your design while implementing it, kids.) So, let's have a look at the class Transition.java --or, rather, at its abstract methods:

  • reset() - Not called by our menu engine. You'll probably use it to "roll back" your animation to the beginning.
  • step() - Called to issue one tick's worth of animating. If you need to move a box 100 pixels in 20 steps, you'd move it 5 pixels in each call of step().
  • doPaintOver() - Mostly intended for the Menu In transition; if this returns true, then no Menu Slices are painted. You could, say, draw some translucent black pixels and return true to "fade out" the last-drawn screen.
  • isDone() - If true, this Transition is removed from the Menu Engine on the next update().
  • requiresReLayout() - If true, call dolayout() on the top-left Slice after calling step() (this is called even if isDone() returns true). Allows developers to use forceToLocation() for Transitions, which can often be much easier (and most definitely faster) than trying to finangle layouts.
  • getNewFocus() - Nearly every transition is meant to change the current Menu Slice, ours being the exception. Use this to return which menu item you want to moveTo() after the Transition finishes.

Given this mini-API, we shall proceed like so:

  • subMenuSlice, captionSlice, jamesPic, and jamesText are the four items that need to be shuffled in whenever we change the menu. However, only subMenuSlice is actually connected to our Cube Slice. So, let's just make 3 copies of these and store the subMenuSlice in an array of Menu Slices, and store that in our Cube Slice's data field.
  • It's a bit unprofessional, but you might check the source and find that RIGHT connected Slices draw last (i.e., "on top"). So, we can connect the old sub-menu to the TOP of the Cube Slice, and the new one to the RIGHT. Note that we are not changing anchors, just paint connections. (This "feature" is up for overhaul as soon as the next release is out.)
  • We'll just make one Transition object, and call reset() to restart the animation. doPaintOver() and getNewFocus() are useless to us; requiresReLayout() will always return true, and isDone() will return true when the sub menu box is at the same y co-ordinate as the old one.
  • We'll also have to set the clip of the new box to the old box's region, so that it appears to slide in from behind the old box.


Step Four: Programming the Animation[edit]

Enough theory. Let's get to work! Our Transition sub-class will be called "SlideDownTransition.java". First, let's make a method called "setTargets()", which help us put all these "new" and "old" menu terms into context:

 public void setTargets(MenuSlice newMenu, MenuSlice oldMenu) {
   this.newSlice = newMenu;
   this.oldSlice= oldMenu;
 }

For convenience, our constructor will also take the Cube Slice as a reference:

 public SlideDownTransition(CubeSlice blueGraphic) {
   this.blueGraphic = blueGraphic;
 }

This will allow us to think in terms of "the new menu and the old one", instead of "Bob's menu", "James's menu", and "Dusty's menu". With this, reset() is a breeze to implement:

 public void reset() {
   //The old menu is currently connected to the right. Move it to the top, and put our new slice on the right
   blueGraphic.disconnect(MenuSlice.CONNECT_RIGHT, MenuSlice.CFLAG_PAINT);
   blueGraphic.connect(oldSlice, MenuSlice.CONNECT_TOP, MenuSlice.CFLAG_PAINT);
   blueGraphic.connect(newSlice, MenuSlice.CONNECT_RIGHT, MenuSlice.CFLAG_PAINT);
 
   //Our new menu is currently in its "final" location. Let's move it up by its height in pixels:
   //We'll also set its clip here. Note that we use oldSlice's get() methods, because we know for 
   //  sure that they're accurate (they were painted at least once).
   newSlice.getInitialFormatArgs().yHint -= oldSlice.getHeight();
   newSlice.setClip(oldSlice.getPosX(), oldSlice.getPosY(), oldSlice.getWidth(), oldSlice.getHeight());
 }

Just to give you an idea how simple this is to activate, we'll re-write our listener code for the Cube Slice here:

 Action makeHighlightAction = new Action() {
   //makeHighlight() was covered earlier and hasn't changed.
   private void makeHighlight(MenuSlice calledBy) {
    //Put the cursor somewhere near the middle of this slice's sub-component
     int[] rectangle = calledBy.getActiveRectangle();
     MetaMenu.currCursor.forceToLocation(
     rectangle[0]+rectangle[2]/2-MetaMenu.currCursor.getWidth(), 
     rectangle[1]+rectangle[3]/2-MetaMenu.currCursor.getHeight()/2);    			
   }
 
   private void startSlideIn(CubeSlice calledBy) {
     //We only transition if we're moving to a NEW menu slice... we need to fail silently on the "focus gained" call.
     MenuSlice oldMenu = calledBy.getConnect(MenuSlice.CONNECT_RIGHT, MenuSlice.CFLAG_PAINT);
     MenuSlice newMenu = null;
     MenuSlice[] savedMenus = (MenuSlice[])calledBy.getData();
     if (calledBy.getCubeSide() == 'L')
       newMenu = savedMenus[0];
     else if (calledBy.getCubeSide() == 'R')
       newMenu = savedMenus[1];
     else if (calledBy.getCubeSide() == 'T')
       newMenu = savedMenus[2];
     if (newMenu.equals(oldMenu))
       return;
 
     //A bit wordy, but now that that's over, let's fire up our transition
     if (mySlideTrans == null) 
       mySlideTrans = new SlideDownTransition(calledBy);
     mySlideTrans.setTargets(newMenu, oldMenu);
     mySlideTrans.reset();
     MetaMenu.currTransition = mySlideTrans;
     return true; //Return value doesn't matter.
   }
 
   public boolean perform(Object caller) {
     CubeSlice calledBy = (CubeSlice)caller;
     makeHighlight(calledBy);
     startSlideIn(calledBy);
   }
 };
 blueGraphic.setSubSelectionChangedListener(makeHighlightAction);
 blueGraphic.addFocusGainedListener(makeHighlightAction);

This assumes that we have a private static variable called "mySlideTrans", and that we've set up the cube's "Data" object correctly. We'll list the full source later, so don't worry if you're not sure what "correctly" means.

Let's consider our "isDone()" method, which is quite simple. The only thing we must remember to do is disconnect oldSlice from the top of our Cube Slice if the transition's done. We can remove the clipping too, for performance reasons.

 public boolean isDone() {
   boolean done = newSlice.getPosY() == oldSlice.getPosY();
   if (done) {
     newSlice.setClip(null);
     blueGraphic.disconnect(MenuSlice.CONNECT_TOP, MenuSlice.CFLAG_PAINT);
   }
   return done;
 }

Next up is step(). With everything else in place, step() is actually quite easy to visualize:

 public void step() {
   int speed = 10;
   int displacement = oldSlice.getPosY() - newSlice.getPosY();
   if (displacement != 0) {
     int amt = Math.min(displacement, speed);
     newSlice.getInitialFormatArgs().yHint += amt;
   }
 }

Finally, our requisite hollow interface specifications:

 public boolean requiresReLayout() { return true; }
 public boolean doPaintOver() { return false; }
 public MenuSlice getNewFocus() { return null; }

Okay, let's fire it up and see how it looks. Here's a somewhat jerky animation:


Fmf custom menu4 animated.gif