One reader recently asked “how to make a shop that is capable of sorting items by various fields like cost, atk dmg, lvl requirement etc.”, so I decided to take a small detour and go ahead and provide a solution. In this lesson we will generate, view, sort, and buy items from a user interface.
In the scene I created an empty panel – it has the buttons for sorting and a ScrollRect area for displaying the merchandise. The scroll area is interacted with via dragging with a mouse or touch (this would work on mobile devices), but I didn’t do anything special to connect it to keyboard or joystick input.
This was just a quick sample, and may never be tied into the Tactics RPG project so I decided to make it a separate demo project which can be downloaded here. The project was created using Unity 3D version 5.1.1f1.
Resources
In order to speed up the development time, I reused several of my own scripts from the Tactics RPG project including Animation, Notification Center, and Pooling scripts. They are included in the complete project, but you can also find copies of them in the project repository here.
All of the art was taken from http://opengameart.org/ from a few different artists. I have an “Attribution” text file in the project in the Textures folder to show who made what and where I found it. In addition I included a free Font with an attribution in its own folder.
Item
First, let’s create a model to hold the data for our shop items. It will need a reference to a sprite (to show an icon of itself), it will have a a name, an attack rating, a level requirement, and a price. This way we have several bits of information to show in the UI as well as several ways to sort them.
using UnityEngine; using System.Collections; public class Item { public Sprite sprite; public string name; public int attack; public int level; public int price; }
Item Generator
There are plenty of options for creating assets- I have suggested a few options in the Tactics RPG series, such as keeping spreadsheets and making editor scripts to parse the data and create Scriptable Objects which can be saved as project assets.
I wanted to keep this project as simple but complete as possible, so I provided a quick generator. For each weapon sprite, this script will pick two words randomly from a list and attempt to combine them into a neat sounding name. Don’t be surprised if it picks something dumb like “sword sword” and uses a picture of a bow. Occasionally it will pick something more acceptable like “mythril dagger” or “sword of woe”. It might make a decent starting point to help flesh out a bunch of game content but would certainly require a polish pass.
If you wanted to create dynamic weapons in a real game you would want to put a bit more care into the system. For starters you could tie certain word(s) to certain pictures, like making sure all swords could pick “sword”, but not “bow”, and then possibly making separate lists of words that could appear before or after the word and still sound good together.
In addition to the name, I add some random stats and try to base the price on the stats I chose.
Once a full list of items are generated, I pass them onto the ItemShop script.
using UnityEngine; using System.Collections; using System.Collections.Generic; using System; public class ItemGenerator : MonoBehaviour { [SerializeField] TextAsset weaponNames; [SerializeField] Sprite[] icons; string[] lines; void Start () { lines = weaponNames.text.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None); List<Item> items = new List<Item>(icons.Length); for (int i = 0; i < icons.Length; ++i) items.Add( Create (icons[i]) ); GetComponent<ItemShop>().Load( items ); } Item Create (Sprite icon) { Item retValue = new Item(); retValue.sprite = icon; retValue.name = RandomName(); retValue.attack = UnityEngine.Random.Range(0, 100); retValue.level = retValue.attack / 10; retValue.price = 50 * (retValue.level + UnityEngine.Random.Range(0, 5)) + 100; return retValue; } string RandomName () { string s1 = lines[ UnityEngine.Random.Range(0, lines.Length) ]; string s2 = lines[ UnityEngine.Random.Range(0, lines.Length) ]; if (UnityEngine.Random.Range(0, 2) == 0) return string.Format("{0} of {1}", s1, s2); return string.Format("{0} {1}", s1, s2); } }
Item Shop
You can think of the ScrollRect as a “Table View” where each row is a “cell” in the table. I created a prefab to reuse as the “cell” and that prefab has another script on it which is already linked up to its own “buy” button. The shop “listens” for any of the “buy” button presses via notifications and then when a purchase is made it sends its own notification with the item that was used to configure the cell. That would be the notification you would want to observe in order to update the user’s inventory or equipment etc.
In the purchase handler, I check the amount of money the user has to verify that a purchase can actually be completed. If not, I prompt the user to obtain more in-game gold with a purchase. This second purchase is “faked” simply by clicking “OK” and could be replaced with something like an In-App-Purchase store where you can spend real money.
When the shop loads for the first time, it has the Pool manager pre-create a bunch of the cells for us. Then it grabs them all and loads each cell with its own item. When I sort the items, I hand all the cells back to the Pool manager and then dequeue and load them again in the new correct order. This is more efficient that destroying and recreating the cells. Note that if our store had hundreds of items (or more), then it wouldn’t be a good idea to create a cell for each and every item. In that kind of case, I would try to only create as many cells as I needed to fill the screen. As the scroll view scrolled, I would determine which cells should be visible, and move / load cells as they would come into view, and pool them as they go out of view.
The whole purpose of this lesson, sorting a generic list, is actually very very simple. You can tell a List to “Sort” itself using an anonymous delegate as a parameter. The delegate simply compare’s fields on two different items. I provided four examples. “Sort By Name” shows that you can sort based on a string field. The other three examples sort based on integer fields. All of the examples except “Sort by Attack” are sorted in “ascending” order. The Attack example is sorted in “descending” order, and was accomplished simply by reversing the order of comparison (compare the second object against the first).
using UnityEngine; using System.Collections; using System.Collections.Generic; public class ItemShop : MonoBehaviour { #region Consts public const string BuyNotification = "ItemShop.BuyNotification"; const string cellKey = "ItemShop.cellPrefab"; #endregion #region Fields [SerializeField] GameObject cellPrefab; [SerializeField] Transform content; List<Item> items; List<Poolable> cells; #endregion #region MonoBehaviour void OnEnable () { this.AddObserver(OnBuyItemNotification, ItemCell.BuyNotification); } void OnDisable () { this.RemoveObserver(OnBuyItemNotification, ItemCell.BuyNotification); } #endregion #region Public public void Load (List<Item> items) { if (cells == null) { GameObjectPoolController.AddEntry(cellKey, cellPrefab, items.Count, int.MaxValue); cells = new List<Poolable>(items.Count); } this.items = items; Reload(); } public void Reload () { DequeueCells(items); } #endregion #region Event Handlers void OnBuyItemNotification (object sender, object args) { ItemCell cell = sender as ItemCell; if (Bank.instance.gold >= cell.item.price) Purchase(cell.item); else GetComponent<DialogController>().Show("Need Gold!", "You don't have enough gold to complete this purchase. Would you like to buy more?", FakeBuyGold, null); } public void OnSortByName () { items.Sort( delegate(Item x, Item y) { return x.name.CompareTo(y.name); } ); Reload(); } public void OnSortByPrice () { items.Sort( delegate(Item x, Item y) { return x.price.CompareTo(y.price); }); Reload(); } public void OnSortByAttack () { items.Sort( delegate(Item x, Item y) { return y.attack.CompareTo(x.attack); }); Reload(); } public void OnSortByLevel () { items.Sort( delegate(Item x, Item y) { return x.level.CompareTo(y.level); }); Reload(); } #endregion #region Private void FakeBuyGold () { Bank.instance.gold += 5000; } void Purchase (Item item) { Bank.instance.gold -= item.price; this.PostNotification(ItemShop.BuyNotification, item); } void EnqueueCells () { for (int i = cells.Count - 1; i >= 0; --i) GameObjectPoolController.Enqueue(cells[i]); cells.Clear(); } void DequeueCells (List<Item> items) { EnqueueCells(); if (items == null) return; for (int i = 0; i < items.Count; ++i) { Poolable obj = GameObjectPoolController.Dequeue(cellKey); obj.GetComponent<ItemCell>().Load(items[i]); obj.transform.SetParent(content); obj.gameObject.SetActive(true); cells.Add(obj); } } #endregion }
Item Cell
This script is attached the the cell prefab I mentioned earlier – one cell is used per row of the table of shop items. It merely contains references to Images and Text fields so that it can display an Item to a user. It also has a Buy button which sends the notification to the store that we are attempting to buy a particular item.
using UnityEngine; using UnityEngine.UI; using System.Collections; public class ItemCell : MonoBehaviour { public const string BuyNotification = "ItemCell.BuyNotification"; public Item item { get; private set; } [SerializeField] Image icon; [SerializeField] Text nameLabel; [SerializeField] Text atkLabel; [SerializeField] Text lvlLabel; [SerializeField] Text priceLabel; public void Load (Item item) { this.item = item; icon.sprite = item.sprite; nameLabel.text = item.name; atkLabel.text = string.Format("ATK:{0}", item.attack); lvlLabel.text = string.Format("LVL:{0}", item.level); priceLabel.text = item.price.ToString(); } public void OnBuyButton () { this.PostNotification(BuyNotification); } }
Dialog Controller
This controller is a simplified version of something I would use in a production app. It has the basic ability to display a reusable dialog box with a title, message, and confirm and cancel buttons. You can pass along delegates to be called based on which of the buttons is actually pressed.
In order to be production ready, I would also want to store the data to display as a separate object and then be able to provide a dialog box “stack” because in theory it is possible that multiple dialog boxes would need to appear at the same time. For example, you are in the middle of making a purchase you can’t afford, and then you lose your internet connection and are now notified of that. When you dismiss the second dialog box, the first should re-appear so you can take the appropriate action in each case that needed a response.
Note that when the dialog box appears it also causes a “blocker” object to appear (a partially transparent black fill over the entire screen) in order to prevent input with anything except the dialog box.
For a little extra polish, I used my Animation libraries to make the Dialog box Show and Hide by animating its scale with a nice easing curve.
using UnityEngine; using UnityEngine.UI; using System; using System.Collections; public class DialogController : MonoBehaviour { [SerializeField] Text titleLabel; [SerializeField] Text messageLabel; [SerializeField] Transform content; [SerializeField] GameObject blocker; Tweener tweener; Action onConfirm; Action onCancel; void Start () { blocker.SetActive(false); content.localScale = Vector3.zero; } public void Show (string title, string message, Action confirm, Action cancel) { titleLabel.text = title; messageLabel.text = message; onConfirm = confirm; onCancel = cancel; blocker.SetActive(true); StopAnimation(); tweener = content.ScaleTo(Vector3.one, 0.5f, EasingEquations.EaseOutBack); } public void Hide () { blocker.SetActive(false); onConfirm = onCancel = null; StopAnimation(); tweener = content.ScaleTo(Vector3.zero, 0.5f, EasingEquations.EaseInBack); } void StopAnimation () { if (tweener != null && tweener.easingControl != null && tweener.easingControl.IsPlaying) tweener.easingControl.Stop(); } public void OnConfirmButton () { if (onConfirm != null) onConfirm(); Hide (); } public void OnCancelButton () { if (onCancel != null) onCancel(); Hide (); } }
Bank
The bank script holds the resources available to the user. I use PlayerPrefs to persist the money between play sessions, and I post a notification any time the amount of Gold contained in the bank changes.
This class was implemented as a normal object (not a Unity GameObject) and uses the Singleton design pattern. This is a very safe way to implement it, because the singleton itself is readonly and CANT be destroyed (unlike a GameObject). You are also unable to create any additional instances of the object because the constructor is private.
Note that a production app would probably need to take additional measures to stop users from cheating. Storing data in the PlayerPrefs is not at all secure. There are plenty of scripts available which provide an Encrypted version of PlayerPrefs and are implemented nearly identically.
using UnityEngine; using System.Collections; public class Bank { #region Consts public const string GoldChanged = "Bank.GoldChanged"; const string GoldKey = "Bank.GoldKey"; #endregion #region Fields public int gold { get { return _gold; } set { if (_gold == value) return; _gold = value; Save (); this.PostNotification(GoldChanged); } } private int _gold; #endregion #region Singleton public static readonly Bank instance = new Bank(); private Bank () { Load(); } #endregion #region Private void Load () { _gold = PlayerPrefs.GetInt(GoldKey, 5000); } void Save () { PlayerPrefs.SetInt(GoldKey, _gold); } #endregion }
Bank View
This simple script is used to show the user how much gold they have available for spending. It listens for notifications from the Bank that its gold value has changed and then animates the display value from the value it had been to the value it should be. I used my Animation libraries for this little bit of extra polish.
using UnityEngine; using UnityEngine.UI; using System.Collections; public class BankView : MonoBehaviour { #region Fields [SerializeField] Text label; EasingControl ec; int startGold; int endGold; int currentGold; #endregion #region MonoBehaviour void Awake () { ec = gameObject.AddComponent<EasingControl>(); ec.equation = EasingEquations.EaseOutQuad; ec.duration = 0.5f; startGold = currentGold = endGold = Bank.instance.gold; label.text = Bank.instance.gold.ToString(); } void OnEnable () { this.AddObserver(OnGoldChanged, Bank.GoldChanged); ec.updateEvent += OnEasingUpdate; } void OnDisable () { this.RemoveObserver(OnGoldChanged, Bank.GoldChanged); ec.updateEvent -= OnEasingUpdate; } #endregion #region Event Handlers void OnGoldChanged (object sender, object args) { if (ec.IsPlaying) ec.Stop(); startGold = currentGold; endGold = Bank.instance.gold; ec.SeekToBeginning(); ec.Play(); } void OnEasingUpdate (object sender, System.EventArgs e) { if (ec.IsPlaying) { currentGold = Mathf.RoundToInt((endGold - startGold) * ec.currentValue) + startGold; label.text = currentGold.ToString(); } } #endregion }
Summary
In this post we created a very simple shop and a dynamic list of shop items. We managed a users resources and prompted them when necessary to obtain more funds. The main purpose was to show how items could be sorted, so we provided four different buttons for four different fields to sort on. We also sorted in both ascending and descending order.
This post was a user requested feature. If anyone has additional requests and they aren’t too time consuming or too far off topic, I would be happy to do more of these.
Small tweak in ItemShop.cs to make the sort stable, i.e. prevent items from flipping when pressing same button multiple times. Uses the name as second sort value:
public void OnSortByPrice()
{
items.Sort(delegate (Item x, Item y) {
int c = x.price.CompareTo(y.price);
if (c != 0)
return c;
return y.name.CompareTo(x.name);
});
Reload();
}
public void OnSortByAttack()
{
items.Sort(delegate (Item x, Item y) {
int c = y.attack.CompareTo(x.attack);
if (c != 0)
return c;
return y.name.CompareTo(x.name);
});
Reload();
}
public void OnSortByLevel()
{
items.Sort(delegate (Item x, Item y) {
int c = x.level.CompareTo(y.level);
if (c != 0)
return c;
return y.name.CompareTo(x.name);
});
Reload();
}
Great comment Danny. CompareTo() returns -1, 0, or 1 depending on if the comparison is less than, equal to or greater than the other compared item. So in the cases where the item values were the same, Danny used a second method of sorting. You can chain as many of these together as you like, such as sorting by level, then attack, then price, then name, etc.
I’ve created a script similar to your ItemCell script and I’m getting an error saying there is no definition for ‘PostNotification’ even though I have all three ‘using’ statements you have included. Am I missing something?
There are a few scripts you need to add to your project. Make sure you have the “Notification Center” scripts in particular. There is a link above in the section titled “Resources”.
Ah, that’s what I was missing, thanks!
It seems I am unable to assign the function call to the prefab, only an object in the scene. Is there a way to assign the function to be called for the OnClick event via script? Would this be bad practice?
Right now I’m still trying to set it up in the board generator scene before setting it up in the game scene.
The trick is to make sure that you have a script with a public method for the button click handler on the same instance as the button component itself. Then you can create a prefab or apply your changes to the prefab, and the handler will be serialized just fine. I did exactly that in this lesson.
You are also able to assign handlers in code, and it is not bad practice, it simply requires you to have more code when you could have connected it ahead of time.
Quick architectural question – Would you recommend creating a class specifically for inventory items even if you already have a class for the object itself?
For example: class ‘InventoryItem’ even though you already have ‘Gun’ and ‘Bullet’ classes?
There are several architectures I “could” take. One way is to make use of polymorphism where you try to define an interface that your ‘Gun’ and ‘Bullet’ classes could implement (either a literal interface or a shared base class) which defined enough fields to populate a store menu.
Another option is to build up your items through components as I do with much of the Tactics RPG project. For example, items with a ‘Merchandise’ component can appear in the store, and some component or combination of components (which also can be created to an interface) on the GameObject should be able to answer common questions like title, description, price, etc. that populate the store’s view.
What I wouldn’t want to do is to try to write a store script which had to know about each and every class of all the items it could sell. If you see classes appear like ‘Gun’ and ‘Bullet’ in your store script you are almost certainly doing something wrong. This will make your code very cumbersome to write and maintain.
I’m using this post as a basis for my inventory system. After reading through your reply and much of the post again, I think this is how I should approach it:
– Item
– Inventory
– InventoryCell
– Storage
– InventoryGenerator, which takes the units in storage and generates Items out of them and then calls the Load() function of Inventory.
I know users can just download the project, but in the future can you expand this post on how you set up the item cell prefab? We have the script but how its contents are populated into the cell itself is unclear to me.
I’d be happy to answer questions, but I am not sure what you mean? Taking screen grabs of the inspector and describing what is there isn’t any more helpful than looking at the actual project and being able to see each item in the inspector and click on or modify the references, etc.
What part do you find unclear? Is it related to how the prefab itself is created? Like a specific component? The architecture in the scripting after the fact?
Specifically, how do you get the ItemCells to show up in that structured (rows with spacing) way?
The ‘Shop Item Cell’ has a ‘Layout Element’ component which specifies the Height it should appear as. It is parented to the content object of a scroll area. The content object has a component called ‘Vertical Layout Group’ which handles the stacking and spacing of the cells.
He adds a vertical layout component to the parent panel of the cells.