Creating Dynamic Scaling Menu Interfaces in cocos2d
In our upcoming iOS title, Haggis (see here for an overview of the game), we are bringing a much loved indie card game to the digital space. I’m going to use the trials and tribulations that my team runs in to on a bi-weekly basis as a series of articles for #iDevBlogADay.
One of our greatest challenges on this project has been developing user interfaces that feel at home on both iPad and the iPhone devices in terms of space and interface. What looks great as a pop-up dialog on an iPad is going to seem too big for an iPhone and feel better as a full screen scrollable summary (especially when we are working with Portrait on the iPad, and Landscape on the iPhone). One approach is to just cram the same interfaces everywhere, which in many cases can work. Our design methodology at IQ Games is to distill interfaces down to the information that the user needs, and then develop a presentation layer that feels at home on each device. Sometimes it is more work, involves different art and assets for both devices, but at the end of the day leaves us with titles that feel at home in both places, as opposed to skewed to work great on one device and leaving the other resolution short changed.
So with that long intro aside, here is our approach to building scaling interfaces for our interstitial “Hand Over” and “Game Over” interfaces that provide information to our users within Haggis.
Getting Started – Wireframing
When we start working on any interface, we always start with paper and black/white graphic wire framing. It helps get all of the thoughts out where they can be poked and prodded and massaged without wasting time building and tinkering in final pixels. Here is where we started with our Hand Over screen:
Notice that this looks like a reasonable screen with a lot of information to present. It is–sometimes.
What happens when there is less content?
Deciding to Scale our Interfaces
Our rationale for moving towards a scaleable interface was that in a given game, you are going to either need all or none of the space available in the “Completely Full” hand over menu. For a two player game, there is a lot of boring dead space.
Our ideal interfaces would scale around the content available, and therefore with the least amount of possible information look like this.
You may first be asking–why do we care?
The full size interface works, right? Sure, it does. But on the iPhone, dead scrollable space in a full screen interface is annoying, and puts less useful information in front of the user. And since we are going to go through all this trouble, why not do it for the iPad popup dialogs too? It will be nicer, and we all like nicer.
The next question becomes–great, now how do we make all of this happen with cocos2d? I’m glad you asked.
Basic Building Blocks of Scaleable Interfaces
There are two basic scaleable interface methods that you will see a lot in interface development. For elements that need to only grow on one axis, either by growing taller OR wider, you can set up a 3 slice scaling pattern. For elements that need to grow in both axes, then you are looking at a 9 slice scale. Since we know we want to maintain the width of our container, and just modify the height, we are going to go into a basic 3 slice scaling object.
In a 3 slice scale, the outer pieces, our top and bottom caps, are of a fixed dimension. The piece that scales in the center, should be designed in a way that you can easily duplicate it to create the pattern you are looking for–or created in the maximum size you will need to use and then creatively masked to the size you want. I will show you both methods here by the time we are done creating this UI.
Our Final UI
Once wireframes are complete, our team moves right into designing the final game interfaces. Having a good wireframe to work on lets you focus on design instead of architecture, and tends to make our design teams more successful when executing creative. Here is a view of our final designs, and an illustration showing how the interface elements will grow and scale with the interface as the data is populated.
The iPad interface will be a popup dialog that the player dismisses by pressing the Continue button at the bottom of the dialog.
The iPhone interface will have a static header with a Continue button fixed to the top right of the interface which is pretty consistent for most iPhone UI. The remainder of the game statistics will be part of a scrolling area below that header so the user can see the main scores, and scroll to see additional information. This is where it is really nice to not have all of that extra information. (The red box in the iPhone interface represents the viewable area of the iPhone screen rotated to portrait.)
The Main Container – Scaling by stretching
The top and bottom pieces of this example will be approximately 56 pixels tall each, and saved into sprite sheets and named accordingly menu_background_top.png and menu_background_bottom.png.
The center segment will be the segment that we are stretching, and here is where the planning and cocos2d fun comes in. The center slice will be the full width of our background image, which is 400 pixels. The height will be only 4 pixels, and these four pixels need to be identical in the vertical direction. Meaning while the pixels may change as they move right to left, everything top to bottom must be an identical column of color. We are going to use the setScaleY method of the CCSprite object to stretch this block of color to the height we need to pull off our design.
Why 4 pixels? Well, you could technically do it with 1. Steffen Itterheim tweeted me to let me know that in some environments there is a 4 or 8 pixel height minimum for textures. I went with 4. If you have problems, go with 8.
Code! Well, not a lot, but I’m hoping that if you are looking for a solution like this you’ve got some experience with cocos2d.
Here are the pieces we are working with:
CCSprite *frameTop = [CCSprite spriteWithSpriteFrame:[[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"gamescene_summary_frame_top.png"]]; CCSprite *frameMiddle = [CCSprite spriteWithSpriteFrame:[[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"gamescene_summary_frame_middle.png"]]; CCSprite *frameBottom = [CCSprite spriteWithSpriteFrame:[[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"gamescene_summary_frame_bottom.png"]]; [self addChild:frameMiddle]; [self addChild:frameTop]; [self addChild:frameBottom];
This gives us the setup of our components, and the next thing we do is decide how large we want our container to be. Once we figure out the size we want, we need to set the middle frame to Not antialias it’s texture. With the texture set to Not antialias (which is helpful when scaling other objects), we calculate the scale multiplier needed to reach our required height, and adjust the scaleY of the frameMiddle object:
int overallTargetHeight = 500; // Determine position of frameTop int backgroundFrameTopPosition = floor((overallTargetHeight/2) - (frameTop.textureRect.size.height / 2)); [frameTop setPosition:CGPointMake(0, backgroundFrameTopPosition)]; // Calculate the size of the middle gap we will need to fill // (assuming top and bottom textures are the same size) // We are dividing by 4 because our texture height is 4 pixels int middleGaptoFillRatio = ceil((overallTargetHeight - (frameTop.textureRect.size.height * 2))/4); // Tell the frameMiddle not to anti-alias, this will cause the texture to be sharp when scaled. [[frameMiddle] texture] setAliasTexParameters]; // Scale the frameMiddle based on our gap math [frameMiddle] setScaleY:floor(middleGaptoFillRatio)];
This results in a background container that fits within your specified overall target height, and looks sharp doing so. This is a fairly simple implementation, but in my final UI there will be plenty of code to calculate how much content we will have and the space it will take up.
The Inner Container – Scaling by masking
The dark blue inside container of the Round Summary dialog is actually used in two places. Right beneath the Round Summary header, and right below that in the Detailed Scoring section.
We put one image of the maximum height we will need for the Detailed Scoring section (because it will be the largest use) and then we implement some neat masking techniques for rectangular masking that are possible since we are using sprite sheets. We also include a single bar cap to put at the bottom of the image for one instance of the blue background. (The second, larger background area has a rounded cap and will be handled the same way).
Rectangular Masking in cocos2d
When using sprite sheets in cocos2d, we have some really handy tricks we can use to implement rectangular masking. Masking of a sprite sheet derived sprite is done by specifying a CGRect that specifies the crop of the larger sprite to take to present your image. Usually this is handled by a sprite sheet generation tool like our favorite here, Zwoptex. This does a great job of making sprites look good, and saves on memory usage.
The side effect is how easily this lets us mask rectangular areas for dynamic interfaces by storing a reference to the original mask, and then using those measurements to substitute our own crop!
// Round Summary Container CCSprite *roundSummaryBackground = [CCSprite spriteWithSpriteFrame:[[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"gamescene_summary_blue_background.png"]]; // Store a reference rectangle of the original size and position of the container rectangle, for redrawing. CGRect roundSummaryBackgroundReferenceRect = [roundSummaryBackground textureRect]; // Create a new rectangle to mask off the rectangle in the size we want (100px) // We are using the original origin position x/y and background width, but modifying our height // Be careful not to use a height greater than the size of your texture or you will see pieces of other sprites CGRect newMaskedRect = CGRectMake(roundSummaryBackgroundReferenceRect.origin.x, roundSummaryBackgroundReferenceRect.origin.y, roundSummaryBackgroundReferenceRect.size.width, 100); // Apply the new texture rectangle to the sprite, and poof, rectangle masked image! [roundSummaryBackground setTextureRect:newMaskedRect]; // Now that we have a masked image, create the bottom cap for our blue stroke border CCSprite *roundSummaryBottomCap = [CCSprite spriteWithSpriteFrame:[[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"gamescene_summary_blue_top.png"]]; [roundSummaryBottomCap setPosition:CGPointMake(0, floor(roundSummaryBackgroundPosition - roundSummaryBackground.textureRect.size.height/2))];
Summing it up.
Well, those are just a couple of methods for how to deal with dynamic interfaces in cocos2d. It took me a good deal of time to figure out methods that worked really well, so I hope you can make use of it. If there is a big outpouring for a demo project, I might put one together–but I think most folks who find themselves facing these sorts of problems are probably beyond the basics.
Thanks for tuning in.