Bad UI will tarnish every good game design decision you’ve ever made about your game.
[toc]
So, you’ve finally finished your game. It has well-thought-out gameplay mechanics that you perfected by talking it out (to yourself in the shower). You didn’t even know you were so clever until you started writing dialogue for your characters, now maybe you can leave coding behind and be a game writer? Maybe write for Conan? Yeah, totally. Now, you did spend way too much time running around with a sound recorder near your parents swimming pool to get that raw water sound you needed for that one level, but the sound design is superb you must say. You finally figured out Photoshop well enough to know that those filters are meant to be used by people that know what those settings mean, and who intentionally change them to make something interesting, not just willy nilly changing settings til something doesn’t look awful. And as best you can tell, your code was 100% bug-free when you uploaded it. You’re a gaming success story and tomorrow you’re sure all your indie game heroes will be emailing you. “They’re not just stupid games now are they, dad!” you’ll get to yell on the phone, self-righteous, vindicated by your success. But… you overlooked one tiny, massive thing. The UI.
The reviews start rolling in.
- “…had to wait through a whole minute of intros and story before the main menu. A minute! For a flash game!” Well, there were a few logo animations I really had to have for the sites I posted it on, and I wanted the user to hear the clever story intro I wrote right up front at the beginning.
- “…no info/feedback on what specific upgrades actually do for the player, you can level up “Gold” but what does that mean…” It’s goooold you idiot! More points means more gold!
- “…takes 5 seconds for the Options screen to animate in so I can update a setting…every single time” But guys, that was a badass animation, I mean, I went to bed around 4:40am after finishing that…
- “…no mute button… have to leave the game and go back to the main menu to adjust the sound…” You could just turn it on or off at the beginning of the game before you started, why interrupt the audio while you’re playing. It provides AMBIANCE!
- “…had to click on every level on the map select screen to see which one to play next…” But.. but.. well, maybe I should’ve done a better job of guiding the player as to which level he/she just unlocked and can play next.
You get the point. If you had bad game mechanics, terrible sound design, etc, but had a great UI, it’d be a bad game. If you had great game mechanics, great sound design, but had bad UI, it still might be a great game, but you’re punishing the player for playing and most likely people will see it as a bad game. You already got the point, I’m preaching to the choir. And why am I preaching anyways? Get back to swabbin’ th’ deck, ye codemonkey!
A quick note on this post, I’ve broken this post into 2 (more) parts. UI Menu Components (sub-part 1) and UI Game Components (sub-part 2). So there’s a whole new post about the UI components I use in the game itself coming in the next few days! If you happen to read this before the other post is done, I should have it finished by friday. These UI posts take a bit more writing and images and stuff, but I’ll have all the component code shared with you by the end of the week, thanks!
Now, some of those example ‘reviews’ above deal more with UX (User Experience) than UI (User Interface), but that’s a whole different blog post. Or a whole different thesis. Being a coder, I’m going to approach UI from a very methodical standpoint of “I have a problem, I need to fix the problem, here’s your solution and it may or may not have an adequately tied bow on it, but it is a solution.” This whole tutorial series has been more or less my approach to creating a TD game, so we’ll continue that trend with my approach to UI. Remember, this is not a UI expert writing this, nor an artist.
I am a terrible, terrible designer. I Photoshop at a 3nd grade level. My stick figures are misshapen and anyone watching my choice in colors will wonder if I did more of the eating of the crayons (non-toxic, they said…) than the using of the crayons. But the fact remains… I want to make games. I wanted to make this demo. You can’t go running to a design friend every 5 minutes when you need a new placeholder graphic for this or that. So if you’ve checked out the finished demo then you already know that the nicest thing one could say about my UI is that it “is functional.” As this is a demo, I wanted to show how I made the very basic of buttons, menus, HUD, etc. Ignore the artwork before your eyes, and see deep, deep, deep down inside where the code beauty exists…
LoadGameButton.as
Problem
As a player, on the main menu screen, I want to be able to create a new game, load a saved game and delete a saved game.
Solution
This is a wide open problem with a large number of solutions. I won’t go into all the ones I didn’t choose, but here’s the way I went. The Solution: I want a single class to handle the logic for this. I need a “New Game” image, a “Load Game” image, a “Delete Game” image. I arbitrarily narrowed the requirements down to the player having 3 “game slots” so they could have 3 different saved games at a time. Each “game slot” (I’m going to drop the quotes now, we’re all adults here)… each game slot will have it’s own instance of this class. For each game slot, if there is no data saved in the slot, display the “New Game” Texture on the Button. If there is saved data in the slot, show the Load Game Texture and also enable a Delete button so the user can delete their saved game. If the user clicks the delete button, I also need a way to warn the player that deleting game data is forever and they may cry if they say yes (<--- Not done in the actual demo but mandatory in your game!!).
Execution
Here are my two button images (hell yeah, beveling and outer glow! I’m gonna take these skills and FrontPage and have the most awesome geocities account in all the WWW!).
And here, you can see the two different states the button could be in. The delete button is below the Load Game button and isn’t visible or clickable on the New Game button slot. Above the buttons you can see the game slot name.
Now we’ll look at the code and go through programmatically what’s going on here.
From src/com/zf/ui/buttons/loadGameButton/LoadGameButton.as
package com.zf.ui.buttons.loadGameButton
{
import com.zf.core.Assets;
import com.zf.core.Config;
import com.zf.core.Game;
import com.zf.utils.ZFGameData;
import org.osflash.signals.Signal;
import starling.display.Button;
import starling.display.Sprite;
import starling.events.Event;
import starling.text.TextField;
import starling.textures.Texture;
public class LoadGameButton extends Sprite
{
public var onGameSelected:Signal;
private var _gameNameTF:TextField;
private var _gameName:String;
private var _gameId:String;
private var _gameData:ZFGameData;
private var _texture:Texture;
private var _gameExists:Boolean;
private var _loadGameBtn:Button;
private var _deleteGameBtn:Button;
public function LoadGameButton(gameName:String, gameId:String) {
_gameName = gameName;
_gameId = gameId;
var o:Object = Game.so.getGameData(_gameId);
_gameExists = !_isEmptyGameDataObject(o);
if(_gameExists) {
_gameData = ZFGameData.fromObject(o);
_texture = Assets.loadGameBtnT;
} else {
_texture = Assets.newGameBtnT;
}
onGameSelected = new Signal();
_loadGameBtn = new Button(_texture);
_loadGameBtn.addEventListener(Event.TRIGGERED, _onClick);
addChild(_loadGameBtn);
_gameNameTF = new TextField(150, 30, _gameName, 'Wizzta', 40, 0xFFFFFF);
_gameNameTF.y = -40;
addChild(_gameNameTF);
if(_gameExists) {
_addDeleteGameBtn();
}
}
public function setCurrentGameData():void {
Config.currentGameSOID = _gameId;
if(_gameExists) {
Config.currentGameSOData = _gameData;
} else {
Game.so.createGameData(Config.currentGameSOID, true);
}
}
public function destroy():void {
_gameNameTF.removeFromParent(true);
_gameNameTF = null;
_loadGameBtn.removeEventListener(Event.TRIGGERED, _onClick);
onGameSelected.removeAll();
if(_deleteGameBtn) {
_removeDeleteGameBtn();
}
}
private function _addDeleteGameBtn():void {
_deleteGameBtn = new Button(Assets.deleteGameBtnT);
_deleteGameBtn.x = 130;
_deleteGameBtn.y = 105;
addChild(_deleteGameBtn);
_deleteGameBtn.addEventListener(Event.TRIGGERED, _onDeleteGameBtnClicked);
}
private function _removeDeleteGameBtn():void {
_deleteGameBtn.removeFromParent(true);
_deleteGameBtn.removeEventListener(Event.TRIGGERED, _onDeleteGameBtnClicked);
_deleteGameBtn = null;
}
private function _onDeleteGameBtnClicked(evt:Event):void {
Game.so.createGameData(_gameId, true);
_removeDeleteGameBtn();
_loadGameBtn.removeFromParent(true);
_loadGameBtn.removeEventListener(Event.TRIGGERED, _onClick);
_loadGameBtn = new Button(Assets.newGameBtnT);
addChild(_loadGameBtn);
_loadGameBtn.addEventListener(Event.TRIGGERED, _onClick);
}
private function _onClick(evt:Event):void {
setCurrentGameData();
onGameSelected.dispatch();
}
private function _isEmptyGameDataObject(obj:Object):Boolean {
var isEmpty:Boolean=true;
for (var s:String in obj) {
isEmpty = false;
break;
}
// If the object has data, see if it has Relevant data
if(!isEmpty && obj.mapsAttempted == 0) {
isEmpty = true;
}
return isEmpty;
}
}
}
- Line 30-31 – taking params passed in and setting them locally. _gameName is the text you see in the image above that says “Game 1” and “Game 2” and the _gameId is “game1” and “game2” respectively.
- Line 33 – call Game.so and get the game data for _gameId
- Line 34 – _gameExists is a Boolean declared on line 25 and we’ll look at that isEmptyGameDataObject in a bit. That function returns if the o:Object has any game data or not (did we successfully get game data or is this a new game)
- Line 37 – if the game exists, pass that o:Object into that ZFGameData helper function that returns a ZFGameData Object from the Object passed in
- Line 38 – set the _texture for the button to be the Load Game button texture
- Line 40 – if the game doesn’t exist, set the _texture for the button to be the New Game button texture
- Line 43 – create a new Signal for onGameSelected. We’ll dispatch that Signal when the player clicks on this button to let the main game know that this is the game the player has chosen to Start/Continue.
- Line 45 – create a new Button, pass in the _texture
- Line 46 – add an event listener to the button so we know when it gets clicked
- Line 47 – add the button to this Sprite
- Line 49-51 – create a new TextField and pass in the _gameName as the text then add it to this Sprite
- Line 54 – if the game does exist, give the user a way to clear their game data. Call the addDeleteGameBtn().
- Line 58-65 – sets the currentGameSOID to _gameId and sets data from _gameData onto Config or if the game doesn’t exist, it creates game data in the right slot
- Line 67-76 – destroys the component
- Line 78-84 – adds the delete game button to this Sprite and adds the click listener
- Line 86-90 – removes the delete game button from this Sprite and removes the click listener
- Line 92 – handles when the delete game button is clicked
- Line 93 – calling createGameData with this gameId means that we overwrite any other game data that was there. UI Note: make sure you add something here that confirms with the player if they really want to delete their saved game or not! This is a few extra functions, a few extra words that I did not do for the demo, but YOU SHOULD for your game.
- Line 94 – remove the delete button since the player can’t delete again the data we just deleted
- Line 95-99 – there is probably a much smarter way to do this. I am removing the “Load Game” button, disposing of it, creating a whole new button with the “New Game” texture, and adding it to this Sprite. Surely there’s a better way, like just updating the Button texture.
- Line 102-105 – when the New/Load game button gets clicked, set the current game data, and dispatch the Signal that this game was selected.
- Line 107 – checks to see if the Object that gets passed in is empty, or if it has significant saved data on it.
- Line 116 – since mapsAttempted is always updated every time the user clicks on a map to play, if this data is 0, then the player never played any maps and hasn’t really done anything with this saved game slot
Now, right off the bat, there is some dumb stuff going on here. Can you spot it? When the Button needs to change it’s Texture, I have to destroy it, create a whole new Button passing in the new texture, and handle removing and readding event listeners. That’s nuts. I should just be able to update change the Button Texture and be done with it. But we can’t. However, I found this helpful post on the Starling Forums on a Multi-State Button that you might find helpful. So, having to remove listeners, destroy the button, create a new Button and add new listeners is a pain, but lets weigh this against how often a player will be deleting their saved game data, and the fact that this is not part of the update-heavy Play State where every cycle and every tick and every saved render really counts. So this works and I’ll leave it like that for now.
MapSelectIcon.as
Problem
As a player, on the Map Select screen, I want to be able to see available maps, get some map info about them when I hover, and click to select the map I want to play next
Solution
Continuing the trend of purely functional, I use a single shrunk-down screenshot of each Map the user can play as the clickable button. When they hover over the image, they get a useless description of the map. And they can click the image to select the map and begin playing. Remember again that, as a hypocrite, I am showing you really terrible UI while telling you how you should have good UI. Most likely you would have a game map or something in the screen’s background, and these individual icons would be a lot smaller. Maybe they’d represent towns on the map, or countries, or just Level 1, Level 2… Either way, displaying good information about each map is a real boon to the player. You may keep track of and display things like: previous high score, summary of enemy waves, presence of epic boss at the end of the map, modifiers existing on that map (‘raining so -10%range/damage to archer towers’), number of lives the player gets on the map, etc. I always love popovers/tooltips as a way of receiving valuable, contextual information about something I am hovering over. But that’s just me.
Execution
So the following two tiny images are loaded into the game and represent the Map 1 and Map 2 icons.
Lets look at those in proper glorious context though…
Below when I refer to the “map data” that gets passed in, I am referring to a new file that I don’t think I’ve gone over yet. We’ll look at this new JSON file now. This is from src/assets/json/maps/mapSelectData.json.
{
"maps": [
{
"id": "map1",
"file": "assets/maps/map1.json",
"title": "First Map!",
"desc": "This first map is easy!"
},
{
"id": "map2",
"file": "assets/maps/map2.json",
"title": "Second Map!",
"desc": "This second map is also easy!"
}
]
}
It contains the list of maps and their map data for the MapSelect State. The “title” and “desc” attributes can be seen above. For the majority of cases, you would have the same types of icons for this map select screen used over and over. I.e. Town icon disabled, town icon enabled, town icon completed, etc. In my demo, I happen to use screenshots of the maps which, upon looking at this file, would go great in here. An attribute for which “iconImage” to use or something. However, I didn’t do that, and instead I’m passing in the texture I want for the button from the MapSelect State class when I initialize the MapSelectIcon’s class.
Alrighty… now, on to the code!
package com.zf.ui.mapSelectIcon
{
import com.zf.core.Assets;
import org.osflash.signals.Signal;
import starling.display.Image;
import starling.display.Sprite;
import starling.events.Touch;
import starling.events.TouchEvent;
import starling.events.TouchPhase;
import starling.text.TextField;
public class MapSelectIcon extends Sprite
{
public var onHover:Signal;
public var onClick:Signal;
private var _icon:Image;
private var _data:Object;
private var _tf:TextField;
public function MapSelectIcon(data:Object, textureName:String) {
_data = data;
_icon = new Image(Assets.ta.getTexture(textureName));
addChild(_icon);
_tf = new TextField(200, 60, _data.title, 'Wizzta', 32, 0xFFFFFF);
_tf.x = -45;
_tf.y = 100;
addChild(_tf);
onHover = new Signal(Object);
onClick = new Signal(Object);
addEventListener(TouchEvent.TOUCH, _onTouch);
}
public function destroy():void {
onHover.removeAll();
onClick.removeAll();
_icon.removeFromParent(true);
_icon = null;
_tf.removeFromParent(true);
_tf = null;
removeFromParent(true);
}
private function _onTouch(evt:TouchEvent):void {
var touch:Touch = evt.getTouch(this);
if(touch)
{
switch(touch.phase) {
case TouchPhase.BEGAN:
onClick.dispatch(_data);
break;
case TouchPhase.HOVER:
onHover.dispatch(_data);
break;
}
}
}
}
}
- Line 24 – get the map data that is passed in
- Line 26 – for the _icon, create a new Image using the textureName that gets passed in. Pass that name along to the getTexture function and make me an Image!
- Line 27 – add that sucker right to this MapSelectIcon Sprite
- Line 29 – create a new starling.text.TextField, pass in _data’s title as the text, set the x/y and add this textfield below the Image
- Line 34-35 – make new Signals that dispatch an Object as data.
- Line 37 – add an event listener to this Sprite for when the player touches this Sprite
- Line 40-51 – destroy this MapSelectIcon Sprite
- Line 53 – handles the touch event
- Line 58-59 – if the player clicks/TouchPhase.BEGAN’s on this Sprite, dispatch the onClick Signal with the map _data
- Line 62-63 – if the player hovers over this Sprite, dispatch the onHover Signal with the map _data. This data then gets displayed in a textarea-like component on the MapSelect State.
GameOptionsPanel.as
Problem
As a player, in the Play State, I need to be able to access game options to change sound settings.
Solution
In the game, I’ve got a very dinky “Options” Button in the top right corner. When the player clicks it, the GameOptionsPanel slides into view and lets the player change Music and SFX volume. These get saved to their main SharedObject data so once the player sets the sound volume, if they load any of the 3 game slots, or creates a new game in any of those slots, the sound settings persist. I don’t think a user would want different sound volumes for different games. That’s an assumption I made that may not be entirely user-friendly. If multiple people are playing on the same computer and they play different games, they may want different sound settings. It’s a chance I’m taking that really isn’t all that costly to have individual sound volumes for each game slot, but, this is the way I set it up.
Execution
Here is the in-game options panel:
As you can see, there is a separate volume control for the music volume and the SFX volume. Currently in the demo, since I don’t have any music playing, I simply run everything on the SFX volume, but were I to add music, I would just choose for the volume param to come from the Music Volume setting and not the SFX Volume. There is also a Quit Button here that lets the player quit out to the map select state. If the player clicks Cancel, no options are updated, and if the player clicks Save then we take the values of each of those volume settings and save them.
In this options panel, I use Feathers controls, specifically the Slider control. And I also use one of their themes, AzureMobileTheme. This theme requires that I include some assets to use it, and those can be found in the project level ./assets/ folder. It contains a fonts and images folder that it uses to skin controls. Alright, let’s look at the code found in com.zf.ui.gameOptions.GameOptionsPanel.as…
package com.zf.ui.gameOptions
{
import com.greensock.TweenLite;
import com.zf.core.Assets;
import com.zf.core.Config;
import flash.geom.Point;
import feathers.controls.Slider;
import feathers.themes.AzureMobileTheme;
import org.osflash.signals.Signal;
import starling.display.Button;
import starling.display.Image;
import starling.display.Sprite;
import starling.events.Event;
import starling.text.TextField;
public class GameOptionsPanel extends Sprite
{
public var onActivated:Signal;
public var onDeactivated:Signal;
public var onQuitGame:Signal;
private var _bkgd:Image;
private var _musicSlider:Slider;
private var _sfxSlider:Slider;
private var _musicTF:TextField;
private var _sfxTF:TextField;
private var _fontName:String = 'Wizzta';
private var _saveBtn:Button;
private var _cancelBtn:Button;
private var _quitBtn:Button;
private var _startPos:Point;
private var _endPos:Point;
private var _activateTween:TweenLite;
private var _deactivateTween:TweenLite;
private var _sfxVolume:Number;
private var _musicVolume:Number;
public function GameOptionsPanel(startPos:Point, endPos:Point) {
_startPos = startPos;
_endPos = endPos;
onActivated = new Signal();
onDeactivated = new Signal();
onQuitGame = new Signal();
addEventListener(Event.ADDED_TO_STAGE, onAddedToStage);
}
private function onAddedToStage(evt:Event):void {
removeEventListener(Event.ADDED_TO_STAGE, onAddedToStage);
_bkgd = new Image(Assets.gameOptionsBkgdT);
addChild(_bkgd);
new AzureMobileTheme(this);
_musicSlider = new Slider();
_musicSlider.name = 'musicVolume';
_musicSlider.minimum = 0;
_musicSlider.maximum = 100;
_musicSlider.value = Config.musicVolume * 100;
_musicSlider.step = 1;
_musicSlider.page = 10;
_musicSlider.x = 160;
_musicSlider.y = 75;
_musicSlider.width = 200;
addChild(_musicSlider);
_sfxSlider = new Slider();
_sfxSlider.name = 'sfxVolume';
_sfxSlider.minimum = 0;
_sfxSlider.maximum = 100;
_sfxSlider.value = Config.sfxVolume * 100;
_sfxSlider.step = 1;
_sfxSlider.page = 10;
_sfxSlider.x = 160;
_sfxSlider.y = 105;
_sfxSlider.width = 200;
addChild(_sfxSlider);
_musicTF = new TextField(50, 32, _getPercentText(Config.musicVolume));
_musicTF.fontName = _fontName
_musicTF.x = 365;
_musicTF.y = 60;
_musicTF.color = 0xFFFFFF;
_musicTF.fontSize = 24;
addChild(_musicTF);
_sfxTF = new TextField(50, 32, _getPercentText(Config.sfxVolume));
_sfxTF.fontName = _fontName
_sfxTF.x = 365;
_sfxTF.y = 90;
_sfxTF.color = 0xFFFFFF;
_sfxTF.fontSize = 24;
addChild(_sfxTF);
_saveBtn = new Button(Assets.optsSaveBtnT);
_saveBtn.x = 380;
_saveBtn.y = 235;
addChild(_saveBtn);
_cancelBtn = new Button(Assets.optsCancelBtnT);
_cancelBtn.x = 200;
_cancelBtn.y = 235;
addChild(_cancelBtn);
_quitBtn = new Button(Assets.optsQuitBtnT);
_quitBtn.x = 20;
_quitBtn.y = 235;
addChild(_quitBtn);
x = _startPos.x;
y = _startPos.y;
visible = false;
touchable = false;
}
- Line 44-45 – I pass in two points, the startPos (position) Point and the endPos (position) Point. I set those locally here. This is basically where the top-left corner of this Sprite is going to start (somewhere out of view) and where it will end (somewhere in the center of the screen). I use the TweenLite lib to move the panel from the startPos to the endPos which we’ll see later.
- Line 47-49 – set up my Signals for when the panel gets activated, deactivated, and when the player clicks the Quit button.
- Line 51 – add the addedToStage listener so I don’t try to do anything until the panel is actually on the stage.
- Line 55 – the panel has been added to stage, so remove the listener.
- Line 56-57 – create a new Image with the gameOptionsBkgdT Texture and add it to this Sprite
- Line 59 – create a new AzureMobileTheme instance and pass it this Sprite so initialize it so we can use the Theme. Here is more info on Feathers themes
- Line 60 – create a new feathers.controls.Slider
- Line 61 – give it a name “musicVolume” so I can use that later to identify which slider is dispatching events
- Line 62-63 – set the minimum and maximum value to 0 and 100 respectively. Note: AS3 volume values are always from 0 to 1. So later we’ll be converting 0-100 down to 0-1.
- Line 64 – set the default value to the Config.musicVolume (0.75 by default) times 100 to get 75, so the initial default value of this slider would be 75.
- Line 65 – “step” lets me set how I want this slider to behave. I set the step value to 1, so when you drag the slider thumb, you can set it to any integer value between 0 and 1. If I set step to 5, then I could set the volume to values like 0, 5, 10, 15…95, 100.
- Line 66 – “page” is a lot like step, but it’s for when the player clicks the bar to the left or right of the thumb. So setting the page to 10 means that if the player’s volume is 75, and I click to the left of the thumb, the value will jump down 10 so it will now be 65. If I clicked again, it would be 55.
- Line 67-70 – set the x/y and width values of the slider and add it to this Sprite
- Line 72-82 – exactly the same as the previous slider, just “sfxVolume” for the name and different x/y values
- Line 84-98 – I’m adding two TextFields, these will have the percent values of the volume, so by default, the text for both would be “75%”. We’ll look at the _getPercentText() later but that basically is what converts 0.75 into “75%”. The text that says “Music Volume:” and “SFX Volume:” are actually on the background image… I cheated 😀 But those could be TextFields just as well and added just like this.
- Line 100-113 – creating my three buttons, Save, Cancel, and Quit. I pass the new Button() the appropriate texture file to use for the button, set its x/y, then add it to this Sprite.
- Line 115-116 – set the x and y values for this GameOptionsPanel to the _startPos x/y
- Line 117-118 – starting off, I want this options panel to remain invisible, and the panel itself will always be touchable = false so the player can’t actually click the panel background image. Look into setting Sprites to touchable = false; if you will not be interacting with them. This is an optimization technique that will save you a little bit here and there.
And continuing on in com.zf.ui.gameOptions.GameOptionsPanel.as…
public function init():void {
_musicVolume = Config.musicVolume;
_sfxVolume = Config.sfxVolume;
_musicSlider.value = _musicVolume * 100;
_sfxSlider.value = _sfxVolume * 100;
}
public function activate():void {
init();
visible = true;
touchable = true;
_addListeners();
TweenLite.to(this, 1, {x: _endPos.x, y: _endPos.y, onComplete: _activateTweenComplete});
}
public function deactivate():void {
_removeListeners();
TweenLite.to(this, 1, {x: _startPos.x, y: _startPos.y, onComplete: _deactivateTweenComplete});
}
public function saveOptions():void {
Config.musicVolume = _musicVolume;
Config.sfxVolume = _sfxVolume;
Config.saveGameOptions();
}
private function _onSaveTriggered(evt:Event):void {
saveOptions();
deactivate();
}
private function _onCancelTriggered(evt:Event):void {
deactivate();
}
private function _onQuitTriggered(evt:Event):void {
deactivate();
onQuitGame.dispatch();
}
private function _sliderChangeHandler(evt:Event):void {
var slider:Slider = Slider(evt.currentTarget),
val:Number = slider.value / 100;
switch(slider.name) {
case 'musicVolume':
_musicVolume = val;
_musicTF.text = _getPercentText(_musicVolume)
break;
case 'sfxVolume':
_sfxVolume = val;
_sfxTF.text = _getPercentText(_sfxVolume)
break;
}
}
private function _getPercentText(percent:Number):String {
return (int(percent * 100)).toString() + '%';
}
private function _activateTweenComplete():void {
onActivated.dispatch();
}
private function _deactivateTweenComplete():void {
visible = false;
touchable = false;
onDeactivated.dispatch();
}
private function _addListeners():void {
_musicSlider.addEventListener(Event.CHANGE, _sliderChangeHandler);
_sfxSlider.addEventListener(Event.CHANGE, _sliderChangeHandler);
_saveBtn.addEventListener(Event.TRIGGERED, _onSaveTriggered);
_cancelBtn.addEventListener(Event.TRIGGERED, _onCancelTriggered);
_quitBtn.addEventListener(Event.TRIGGERED, _onQuitTriggered);
}
private function _removeListeners():void {
_musicSlider.removeEventListener(Event.CHANGE, _sliderChangeHandler);
_sfxSlider.removeEventListener(Event.CHANGE, _sliderChangeHandler);
_saveBtn.removeEventListener(Event.TRIGGERED, _onSaveTriggered);
_cancelBtn.removeEventListener(Event.TRIGGERED, _onCancelTriggered);
_quitBtn.removeEventListener(Event.TRIGGERED, _onQuitTriggered);
}
}
}
- Line 1-6 – init() gets called by the activate() function a few lines down. This function initializes the volume variables from Config settings, and tells the _musicSlider and _sfxSlider what their value should be. This inits my sliders with the latest Config values basically, in case they got set via a different options panel or something somewhere else in the game (mute button or something).
- Line 8 – activate() gets called by HudManager when the Options button is clicked. This function makes the options panel show up and slide in
- Line 9 – call init() that we just saw
- Line 10 – make the panel visible now
- Line 11 – make the panel touchable now so any TouchEvents will register on this panel (for the buttons)
- Line 12 – adds the event listeners, we’ll look at this function later. This is here so I don’t have active event listeners on Objects that aren’t visible on screen
- Line 13 – calling TweenLite.to(). I have already set this panel’s x/y position to the _startPos x/y. So since I’m activating the panel, I want to tween this Sprite (the first param, “this”) To a specific position and I want that tween to take 1 second (second param), and then I pass TweenLite an Object that contains params. This tween will take the panel from it’s start position To {x: _endPos.x, y: _endPos.y… so over 1 second, move this panel to those endPos coordinates, and onComplete: call _activateTweenComplete function }
- Line 16-19 – deactivate() does the exact opposite. Remove the event listeners, and tween this panel back to the startPos x/y, and when the tween completes, call _ _deactivateTweenComplete()
- Line 21-25 – saveOptions() gets called when the player clicks the Save button. Set the Config volume vars to whatever local volume values the player changed to. Then call Config.saveGameOptions() so we persist those to the SharedObject
- Line 27-30 – called when the Save button is clicked, we call the saveOptions() function then deactivate() to get the panel out of the screen
- Line 32-34 – called when the Cancel button is clicked, basically just call deactivate() since we don’t have to save or do anything
- Line 36-39 – called when the Quit button is clicked, we want to deactivate() the panel and then dispatch that the player clicked the quit button so we need to quit thte game.
- Line 41 – this function handles when a player interacts with either of the Slider components on the panel
- Line 42 – evt.currentTarget has a reference to the actual Slider that changed, typecast that to a Slider Object and put that into var slider:Slider so we can interact it
- Line 43 – get the slider.value and divide by 100. The slider.value will be between 0-100 and we need a value of 0-1 for the volume. So val then easily becomes 0-1 after we divide.
- Line 45 – this is why we set a name param on the sliders, so that we could switch based on the name. You could probably also get away with switch(slider) and then case _musicSlider: but I like the more verbose name or id to switch on
- Line 46-53 – if the musicVolume slider changed, push the val to _musicVolume, update the TextField with the new text value. If it was sfxVolume do the same but for the _sfxVolume value and TextField
- Line 58 – this function takes a Number which will be from 0-1, multiplies it times 100 and makes a String out of it, then adds the % so you get nice pretty “75%” text. You have to typecast to int() because sometimes you will end up with “74.9934536534%” if you don’t.
- Line 62 – this gets called when the activate tween finishes and the panel is finished moving and on stage
- Line 66-68 – called when the deactivate tween finishes and the panel is off the screen, set visible to false to hide it, set touchable to false to keep Starling from thinking players can click off the stage somehow, and dispatch the onDeactivated Signal.
- Line 71-85 – these two functions simply add or remove event listeners from all the Objects on the panel that need events listened to.
- Line 86 – there is a destroy() function here but it is boring and just removes all these items from the Sprite
UpgradeOption.as
Problem
As a player, on the Upgrade screen, I want to see how many upgrade points I have, the different options that I can upgrade, and I want to be able to add or remove to those points at any time. I also want a “Reset” button so if I’ve added a lot of points, I can ‘clear the board’ and start allocating points from 0.
Solution
So, I wanted to hit the core points of the problem. Currently the Feathers NumericStepper is in beta, so it’s not actually available for me to use here. I was bummed about that. So I’ll make my own in a way. So I needed art assets: a + and – button with enabled and disabled states, and I wanted something that showed off that I was “putting points into it” so I went with the following that I drew all by myself!!
Execution
In the project folder, these get drawn onto my atlas.png tilesheet, but you can find the individual images in src/assets/images/ui
In order from Left to Right: upgrade_add_disabled.png, upgrade_add_enabled.png, upgrade_rank_notselected.png, upgrade_rank_selected.png, upgrade_sub_disabled.png, upgrade_sub_enabled.png.
And here we can see them in the game screen.
Available Points are displayed in the upper right with the reset button, and to the left we’ve got our actual UpgradeOptions.as component. It’s made up of a text field with the name of the upgrade, a minus button, a plus button, and 5 ‘ranks’ that can be filled up.
Tragically missing from this design, which you should implement in your game, is what each point in each rank actually does for you. This is not documented anywhere, but right now, each point you put into Tower Attack Speed decreases time by 10%, Tower Range gets you a 15% range boost for the tower, and Tower Damage gives you 20% per rank. These should be built into the UI in a helpful way so the player always knows the cost of each decision.
Now let’s look at the code from com.zf.ui.upgradeOption.UpgradeOption.as. It basically handles creating a reusable UI component where we can pass in a few params and create those three “Tower Attack Speed”, “Tower Range” and “Tower Damage components.
package com.zf.ui.upgradeOption
{
import com.zf.core.Assets;
import org.osflash.signals.Signal;
import starling.display.Button;
import starling.display.Image;
import starling.display.Sprite;
import starling.events.Event;
import starling.text.TextField;
public class UpgradeOption extends Sprite
{
public var optionChanged:Signal;
private var _id:String;
private var _label:String;
private var _labelTF:TextField;
private var _enabled:Boolean;
private var _totalRanks:int;
private var _currentRanks:int;
private var _bonusPerRank:Number;
private var _plusBtn:Button;
private var _minusBtn:Button;
private var _ranks:Array;
private var _onStage:Boolean = false;
public function UpgradeOption(id:String, label:String, numRanks:int, bonus:Number, enabled:Boolean = false) {
_id = id;
_label = label;
_totalRanks = numRanks;
_bonusPerRank = bonus;
_enabled = enabled;
optionChanged = new Signal(Boolean, Object);
addEventListener(Event.ADDED_TO_STAGE, onAddedToStage);
}
public function onAddedToStage(evt:Event):void {
removeEventListener(Event.ADDED_TO_STAGE, onAddedToStage);
_onStage = true;
_ranks = [];
_labelTF = new TextField(200, 35, _label, 'Wizzta', 30, 0xFFFFFF);
_labelTF.y = -40;
addChild(_labelTF);
update();
}
public function update():void {
_updateButtons();
_updateRanks();
}
private function _updateButtons():void {
_addPlusButton();
_addMinusButton();
}
private function _updateRanks():void {
var rank:Image,
texture:String = '';
for(var i:int = 0; i < _totalRanks; i++) {
if(i < _currentRanks) {
texture = 'upgrade_rank_selected'
} else {
texture = 'upgrade_rank_notselected';
}
rank = new Image(Assets.ta.getTexture(texture));
rank.x = 70 + (30 * i);
rank.y = 0;
addChild(rank);
_ranks.push(rank);
}
}
public function set currentRanks(i:int):void {
_currentRanks = i;
if(_onStage) {
update();
}
}
public function getOptionValue():Object {
return {
'id': _id,
'label': _label,
'totalRanks': _totalRanks,
'currentRanks': _currentRanks,
'bonusPerRank': _bonusPerRank
}
}
public function disable():void {
_enabled = false;
update();
}
public function enable():void {
_enabled = true;
update();
}
- Line 29 - id gives this component an ID to use and be called by
- Line 30 - label is used for the TextField text
- Line 31 - totalRanks is how many total dots we will draw out
- Line 32 - bonusPerRank is something like 0.15 or 0.2 for 15% or 20% bonus per rank. These could be used later to provide the player feedback with what each rank does
- Line 33 - since the Upgrade State is managing all of these UpgradeOption components, I need a way to disable all of them (if there are no more available points to spend) and the flag lets me enable or disable the component
- Line 34 - optionChanged Signal lets me dispatch out when a player has clicked a + or - button and the value of this option has changed.
- Line 41 - after the component is on the stage, set the _onStage flag
- Line 42 - initialize _ranks to an empty array
- Line 44-46 - set up the _labelTF TextField and add it to this Sprite
- Line 47 - then call update()
- Line 50-53 - update() is called to update the visual elements of this component, it calls _updateButtons() and _updateRanks()
- Line 55 - _updateButtons() is a wrapper for adding both plus and minus buttons
- Line 60 - _updateRanks() updates the rank UI images based on currentRanks
- Line 63 - loop through the _totalRanks (in my demo currently, these are set to 5)
- Line 64 - _currentRanks would be the number of points you've placed into this option already, so if you had 2 points invested out of 5, when i is 0 and 1 it will use the 'upgrade_rank_selected' texture, otherwise it uses the notselected
- Line 70-73 - rank is just an Image that uses the texture previously set up in the lines before it. I'm starting the x value at 70, then for every rank after i'm adding 30. Set the y to 0 and add it to the Sprite
- Line 75 - push the rank Image onto the _ranks array so we can easily remove them later
- Line 79 - set currentRanks() takes an int and sets the number of ranks that are selected, then updates the graphics if this UpgradeOption is already on the stage
- Line 86 - getOptionValue() takes the relevant private values on this class, and returns them in a nice, pretty Object
- Line 96 - disable() sets _enabled to false and calls update()
- Line 101 - enable() sets _enabled to true and calls update()
And continuing with UpgradeOption.as...
private function _addPlusButton():void {
// check if the _minusBtn already exists on stage
if(contains(_plusBtn)) {
_plusBtn.removeFromParent(true);
}
var texture:String = 'upgrade_add_disabled',
addListener:Boolean = false;
if(_enabled && _currentRanks < _totalRanks) {
texture = 'upgrade_add_enabled';
addListener = true;
}
_plusBtn = new Button(Assets.ta.getTexture(texture));
_plusBtn.x = 40;
_plusBtn.y = 0;
_plusBtn.name = 'add';
addChild(_plusBtn);
if(addListener) {
_plusBtn.enabled = true;
_plusBtn.addEventListener(Event.TRIGGERED, _onButtonClicked);
} else {
_plusBtn.enabled = false;
_plusBtn.removeEventListener(Event.TRIGGERED, _onButtonClicked);
}
}
private function _addMinusButton():void {
// check if the _minusBtn already exists on stage
if(contains(_minusBtn)) {
_minusBtn.removeFromParent(true);
}
var texture:String = 'upgrade_sub_disabled',
addListener:Boolean = false;
if(_enabled && _currentRanks > 0) {
texture = 'upgrade_sub_enabled';
addListener = true;
}
_minusBtn = new Button(Assets.ta.getTexture(texture));
_minusBtn.x = 10;
_minusBtn.y = 0;
_minusBtn.name = 'sub';
addChild(_minusBtn);
if(addListener) {
_minusBtn.enabled = true;
_minusBtn.addEventListener(Event.TRIGGERED, _onButtonClicked);
} else {
_minusBtn.enabled = false;
_minusBtn.removeEventListener(Event.TRIGGERED, _onButtonClicked);
}
}
private function _addListeners():void {
_plusBtn.addEventListener(Event.TRIGGERED, _onButtonClicked);
_minusBtn.addEventListener(Event.TRIGGERED, _onButtonClicked);
}
private function _removeListeners():void {
_plusBtn.removeEventListener(Event.TRIGGERED, _onButtonClicked);
_minusBtn.removeEventListener(Event.TRIGGERED, _onButtonClicked);
}
private function _onButtonClicked(e:Event):void {
var addedRank:Boolean = false;
if(Button(e.currentTarget).name == 'add') {
_currentRanks++;
addedRank = true;
} else {
_currentRanks--;
}
update();
optionChanged.dispatch(addedRank, getOptionValue());
}
public function destroy():void {
_labelTF.removeFromParent(true);
_labelTF = null;
for(var i:int = 0; i < _ranks.length; i++) {
_ranks[i].removeFromParent(true);
_ranks[i] = null;
_ranks.splice(i, 1);
}
_plusBtn.removeFromParent(true);
_plusBtn = null;
_minusBtn.removeFromParent(true);
_minusBtn = null;
}
}
}
- Line 1-54 - these next two functions _addPlusButton() and _addMinusButton() are perfect candidates for some easy refactoring at a later date. I don't know why I didn't do this to start with, but here we are. These are essentially the exact same functions but they just have a few differences in the texture names to use, the name of the button, and the x value. I'll go through the first function and you can apply those to the second. Oh, and some logic on how it enables/disables itself.
- Line 3-5 - As the comment said, check to see if _plusBtn is already on this Sprite, if so, remove it
- Line 9-12 - if this component is enabled and the _currentRanks is less than _totalRanks (meaning: I've got 3 points into this component that has a total of 5 points) then I can add more points to the component so set the texture to the add_enabled image and addListener is true (because I'll want to add the event listeners)
- Line 14-18 - creates a new Button with the proper texture and adds it to this Sprite
- Line 20-26 - if addListener is true, add the event listeners to the plusBtn and enable it. If not, disable it (grays out the button) and remove the event listener
- Line 56 - wrapper function to add event listeners to both buttons
- Line 61 - wrapper function to remove event listeners to both buttons
- Line 66 - event handler for when both the plus and minus buttons are clicked
- Line 68-73 - check the name of the Button, if it's 'add', then increment _currentRanks and set addedRank to true, otherwise decrement _currentRanks and leave addedRank to false.
- Line 75 - update() is called to update the UI
- Line 76 - optionChanged Signal is dispatched with if we added a rank or not, and we get the significant private variables, wrap them in an Object and send them off with this Signal as well
- Line 79-94 - boring standard destroy() function except for 83-87 where we loop through the _ranks images, remove them from this Sprite, remove them from the _ranks array and null them out.
All of the other work is handled by the Upgrades State which we'll look at another time.
Feel free to check out the finished product of my AS3 Starling TD Demo.
You can also find all of the code used in srcview.
Or you can download the whole project zipped up.
Or check out the repo on Bitbucket
Thanks!
-Travis
0 Comments