Lesson Objectives
At the end of this lesson, you will know…
- How to manually bind list properties of custom classes, to automatically update comboboxes in the UI
Manually binding list properties to a combobox
It seems like we could do the same thing to the comboboxes that we did to bind the Inventory to a datagridview. However, there are a couple of problems.
First, the Player.Inventory property contains a list of InventoryItem objects, and we only want a list of the Details property for each InventoryItem object. There isn’t an automatic way to get those values as a bindable list.
Second, we want to apply a filter, so we only include items that are weapons (for the cboWeapons combobox) or potions (for the cboPotions combobox).
So, we need to use a different technique.
When we make this change, we’ll also create a function in the Player class to remove items from inventory. Then, we will change SuperAdventure.cs to use the new function. This is another part of moving our logic code out of the UI code.
Step 1: Open the Player.cs class in Visual Studio.
The first thing we need to do is create properties for the lists of our weapons and potions – to bind to the comboboxes.
It would be nice if we could bind the combobox to the Inventory property, and filter it to only include items that are weapons. However, there are limits to what you can do with the different types of collections and lists in .NET. So, we need to create new properties on the Player class, and do our binding manually.
Create these new properties in Player.cs:
public List<Weapon> Weapons { get { return Inventory.Where(x => x.Details is Weapon).Select(x => x.Details as Weapon).ToList(); } } public List<HealingPotion> Potions { get { return Inventory.Where(x => x.Details is HealingPotion).Select(x => x.Details as HealingPotion).ToList(); } }
These use LINQ to create a new list of Inventory items where the Details property is the datatype Weapon (or HealingPotion). The “is” is used to check the datatype of an object. Remember that the Details property, of the InventoryItem class, holds objects whose datatype is “Item”. The Weapon class inherits from the Item class. So, Weapon objects have a datatype of both Weapon and Item.
Think of it like the base class is “Animal”, and the child class is “Dog”. A poodle is both an animal and a dog. So a poodle can fit in a list of Animal objects, and a list of Dog objects.
The “Select(x => x.Details)” returns only the Details property of the InventoryItem object. We don’t use InventoryItem’s Quantity in the comboboxes, so we only take the Details value.
The “ToList()” converts the results of the LINQ query into a new list. We will bind these lists to the comboboxes.
Step 2: Add this new function to Player.cs. We will use this to notify the UI when the inventory changes.
private void RaiseInventoryChangedEvent(Item item) { if(item is Weapon) { OnPropertyChanged("Weapons"); } if(item is HealingPotion) { OnPropertyChanged("Potions"); } }
When we add (or remove) anything in the Inventory list, we will call this method. If the item was a weapon, it will raise an event saying that the “Weapons” property has been changed. It will do the same for the “Potions” property, when we add or remove potions.
Step 3: Add this new function to Player.cs. From now on, we will call this when we want to remove an item from the player’s inventory.
public void RemoveItemFromInventory(Item itemToRemove, int quantity = 1) { InventoryItem item = Inventory.SingleOrDefault(ii => ii.Details.ID == itemToRemove.ID); if(item == null) { // The item is not in the player's inventory, so ignore it. // We might want to raise an error for this situation } else { // They have the item in their inventory, so decrease the quantity item.Quantity -= quantity; // Don't allow negative quantities. // We might want to raise an error for this situation if(item.Quantity < 0) { item.Quantity = 0; } // If the quantity is zero, remove the item from the list if(item.Quantity == 0) { Inventory.Remove(item); } // Notify the UI that the inventory has changed RaiseInventoryChangedEvent(itemToRemove); } }
When we call this, we pass the item we want to remove, and the quantity to remove.
Notice that the quantity parameter has an ” = 1″ after it. That makes it an optional parameter. If we call this function with only an item, it will assume we want to remove one of it. If we want to delete more than one of that item, we can pass in the number to remove for this parameter.
There are two comments in this code about raising an error – when you try to remove an item from inventory that doesn’t exist in the player’s inventory, and when you try to remove a quantity larger than what they have in their inventory.
If the remaining quantity of the item is zero, we completely remove that item from the Inventory list.
In the last line, we call the function to send the UI a property change notification.
Step 4: Now we need to change the Player class functions to use the new RemoveItemFromInventory function – to raise the notification events when the inventory changes.
Replace the existing RemoveQuestCompletionItems() and AddItemToInventory() functions with this code:
public void RemoveQuestCompletionItems(Quest quest) { foreach(QuestCompletionItem qci in quest.QuestCompletionItems) { // Subtract the quantity from the player's inventory that was needed to complete the quest InventoryItem item = Inventory.SingleOrDefault(ii => ii.Details.ID == qci.Details.ID); if(item != null) { RemoveItemFromInventory(item.Details, qci.Quantity); } } } public void AddItemToInventory(Item itemToAdd, int quantity = 1) { InventoryItem item = Inventory.SingleOrDefault(ii => ii.Details.ID == itemToAdd.ID); if(item == null) { // They didn't have the item, so add it to their inventory Inventory.Add(new InventoryItem(itemToAdd, quantity)); } else { // They have the item in their inventory, so increase the quantity item.Quantity += quantity; } RaiseInventoryChangedEvent(itemToAdd); }
We want to use these functions every time we add or remove items in the player’s inventory. There are not many places right now. However, if we add vendors to the game, and let the player buy and sell items, we will use these two functions. That way, we know the event notification will always be raised for the UI.
Step 5: Now we need to modify SuperAdventure.cs. The first thing we’ll do is set up the comboboxes to bind to the new Player properties.
In the constructor for SuperAdventure.cs, add these lines before the “MoveTo(_player.CurrentLocation);”
cboWeapons.DataSource = _player.Weapons; cboWeapons.DisplayMember = "Name"; cboWeapons.ValueMember = "Id"; if(_player.CurrentWeapon != null) { cboWeapons.SelectedItem = _player.CurrentWeapon; } cboWeapons.SelectedIndexChanged += cboWeapons_SelectedIndexChanged; cboPotions.DataSource = _player.Potions; cboPotions.DisplayMember = "Name"; cboPotions.ValueMember = "Id"; _player.PropertyChanged += PlayerOnPropertyChanged;
These lines say to use the Weapons and Potions properties as the datasources for the comboboxes. We say what property to display in the combobox (the DisplayMember) and what property to use as the value (the ValueMember) when we check for the currently selected item.
We also set the SelectedItem to the player’s CurrentWeapon – if they have one.
There are two connections to event handlers:
- “cboWeapons_SelectedIndexChanged”, the existing function for when the player chooses a new weapon in the combobox.
- “PlayerOnPropertyChanged” – a new function to update the combobox data when the player’s inventory changes.
Step 6: Add the PlayerOnPropertyChanged function to SuperAdventure.cs. This will update the combobox data when the player’s inventory changes.
private void PlayerOnPropertyChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs) { if(propertyChangedEventArgs.PropertyName == "Weapons") { cboWeapons.DataSource = _player.Weapons; if(!_player.Weapons.Any()) { cboWeapons.Visible = false; btnUseWeapon.Visible = false; } } if(propertyChangedEventArgs.PropertyName == "Potions") { cboPotions.DataSource = _player.Potions; if(!_player.Potions.Any()) { cboPotions.Visible = false; btnUsePotion.Visible = false; } } }
The “propertyChangedEventArgs.PropertyName” tells us which property was changed on the Player object. This value comes from the Player.RaiseInventoryChangedEvent function, where it says OnPropertyChanged(“Weapons”), or OnPropertyChanged(“Potions”).
We re-bind the combobox to the Weapons (or Potions) DataSource property, to refresh it with the current items.
Then, we see if the lists are empty, by using “!_player.Weapons.Any()”. Remember that Any() tells us if there are any items in the list: “true” if there are, “false” if there are not. So, we are saying, “if there are not any items in the list, set the visibility of the combobox and ‘Use’ button to false (not visible)”.
This is in case we use our last potion in the middle of a fight. Since the player’s Potions property will be an empty list, it will hide the potions combobox and “Use” button.
Step 7: With the binding in place, we can remove the old code we used to manually refresh the comboboxes.
Look for the UpdateWeaponListInUI() function (line 235) and the UpdatePotionListInUI() function (line 275), and delete both of them.
Use Ctrl-F to find where we called those two functions and delete those lines. They are at the end of the MoveTo() function, in the bntUseWeapon_Click() function, and in the btnUsePotion_Click() function.
Step 8: The final change is to make the btnUsePotion_Click() function use our new Player.RemoveItemFromInventory() function, when the player uses a potion.
Change this code:
// Remove the potion from the player's inventory foreach(InventoryItem ii in _player.Inventory) { if(ii.Details.ID == potion.ID) { ii.Quantity--; break; } }
To this:
// Remove the potion from the player's inventory _player.RemoveItemFromInventory(potion, 1);
Now we aren’t directly changing the player’s inventory from the UI. It has to pass through our function in the Player object, which will send up a notification that the player’s inventory has changed.
Step 9: We’ll also want to use the new Weapons and Potions properties to show, or hide, the comboboxes and buttons when the player moves to a new location and encounters a monster.
In SuperAdventure.cs, change lines 253-256 from this:
cboWeapons.Visible = true; cboPotions.Visible = true; btnUseWeapon.Visible = true; btnUsePotion.Visible = true;
to this:
cboWeapons.Visible = _player.Weapons.Any(); cboPotions.Visible = _player.Potions.Any(); btnUseWeapon.Visible = _player.Weapons.Any(); btnUsePotion.Visible = _player.Potions.Any();
Step 10: Run the program, and make sure it works.
Summary
We’ve moved more logic code where it belongs – in the classes of the Engine project. We’ve also provided a central place to remove items from the player’s inventory.
Players won’t see a change in how the game works. However, we are making it much easier for us to work with in the future.
Source code for this lesson
Next lesson: Lesson 20.5 – Moving the game logic functions from the UI project to the Engine project
Previous lesson: Lesson 20.3 – Binding list properties to datagridviews
All lessons: Learn C# by Building a Simple RPG Index
Brilliant tutorial but I’m having a problem after following this – when I get more than one of an item, the text in the Inventory data grid disappears but the number remains! I’ve copied and pasted the source code exactly but I’m not sure what else to do!
Thank you. I think the most likely source of the problem might be that the Item objects don’t have values for their NamePlural property. Check the PopulateItems function in the World class https://gist.github.com/ScottLilly/803df1021fbc404b38f5, and make sure the plural value is correct.
The next place to look would be in the constructor of the Item class. Make sure the namePlural parameter is being saved to the NamePlural property. Since the parameter name and property name are the same (other than the lower/upper-case “N”), it’s easy to mis-type something there.
Please let me know if that isn’t the source of the problem, then we could check other places.
Nope sorry, ignore me, I didn’t check the Item class and the left hand side had a capital N, not a lower case. That’s fixed the problem, thank you!
You’re welcome.
Hi Scott,
Great lesson though I have some comments:
In your GitHub code, lines 253 to 256 in SuperAdventure.cs have been modified to use the
Weapons
andPotions
list to set the visibilities of the combo boxes and buttons, but this isn’t mentioned in the lesson. Without these adjustments, my combo boxes and buttons are always visible in a fight, even when I have no weapons or potions.In the lesson, we no longer set
cboWeapons.SelectedIndex = 0
when_player.CurrentWeapon == null
. In a new game with a defaultPlayer
, this caused mycboWeapons
to have a ‘blank’ item as the default weapon instead of my Rusty Sword. When I ‘used’ this blank weapon, my program crashed.I am also trying to use WPF instead of WinForms for these lessons, so the problems may lie there instead.
Thanks in advance for the clarification!
Thanks Julian.
I added a step to show setting the visibility of the comboboxes and buttons, when encountering a monster at the new location. For the SelectedIndex, I think the combobox in Windows Forms automatically treats the first item (index 0) as the SelectedIndex, if you do not manually set a SelectedIndex value. You might need to specifically set it, with a WPF combobox. I will do a test, to check the exact behavior.
Hi, now when I use an Item I added, every time a loot the item, which is a weapon, it changes the combo box back to the Rusty Sword, I though it was something I wrote bad but I copy pasted Player.cs and SuperAdventure.cs from github to try and I get the same
Can you upload your solution files (including the files in the sub-directories) to Dropbox or GitHub? Then I can search for the problem. It might be a couple days before I can investigate.
Hi! I’m having the same issue as Julian.
I changed the Spider Fang item into a Weapon.
Every time I loot a weapon, the AddItemToInventory() function calls the RaiseInventoryChangedEvent() function, and the OnPropertyChanged(“Weapons”); call reloads the content of the cboWeapons combobox. Then the combobox calls the cboWeapons_SelectedIndexChanged() function, and changes the _player.CurrentWeapon property back to the default Rusty sword. I’m not sure how to solve this problem elegantly.
I’m simultaneusly developing a WinForms and a WPF user interface in this GitHub repository:
https://github.com/ArpadGBondor/WPF-Simple-RPG-tutorial-project
You can check my “WinForms 20.4” commit, to see the problematic source code.
The WinForms UI follows the instructions in this tutorial, and I’m trying to solve everything in WPF myself, after each lesson.
My solution is to change the Weapon part of the PlayerOnPropertyChanged() function in the SuperAdventure class.
if (propertyChangedEventArgs.PropertyName == “Weapons”)
{
Weapon playerWeapon = _player.CurrentWeapon;
List weaponList = _player.Weapons;
cboWeapons.DataSource = weaponList;
if (weaponList.Where(p => p.ID == playerWeapon.ID).Any())
{
cboWeapons.SelectedItem = playerWeapon;
}
if (!_player.Weapons.Any())
{
cboWeapons.Visible = false;
btnUseWeapon.Visible = false;
}
}
Hi Red,
I thought this was fixed in one of the lessons, but I can’t find where. Your fix is good, although you shouldn’t need to have the weaponList variable. You should still be able to just set cboWeapons.DataSource to _player.Weapons.
if(propertyChangedEventArgs.PropertyName == "Weapons")
{
Weapon previouslySelectedWeapon = _player.CurrentWeapon;
cboWeapons.DataSource = _player.Weapons;
// Handle the possibility that the player does not have any weapons, or sold their weapon.
if (previouslySelectedWeapon != null &&
_player.Weapons.Exists(w => w.ID == previouslySelectedWeapon.ID))
{
cboWeapons.SelectedItem = previouslySelectedWeapon;
}
if(!_player.Weapons.Any())
{
cboWeapons.Visible = false;
btnUseWeapon.Visible = false;
}
}
I think this should work, and might handle a couple more “edge cases” (uncommon situations). Please let me know if you try this, and see any problems.
Hello again, Mr. Lilly.
I’ve run into a bit of a snag while trying to properly bind abilities (a property I’ve made myself) to their own combobox. I believe the data binding works just fine, as, if the player has abilities, the most recent selection is saved and the combobox acts just like the weapon and potion boxes. The problem is that, seemingly, the only way to have abilities now is to add them before the data binding for the ability combobox happens. If I add abilities to the player’s ability list before binding, they show up just fine. The way players are supposed to get abilities –learning them by reaching a certain level– just results in an empty combobox because, I assume, the abilities aren’t being added to the list properly or the list isn’t being updated after binding occurs. But it’s the same code that adds abilities both before and after data binding (just on different lines and at different times, naturally), and I don’t see anything out of the ordinary for how I did the data binding for the ability box compared to the other two, and they can have new weapons and items added to them just fine.
There’s also another slight problem I’m having implementing another new feature: weaknesses. Abilities and weapons can have properties that increase damage done to an enemy if the properties match any of its designated weaknesses. How it’s supposed to work is that, when the player attacks the monster, a check is done during damage calculation to see if the attack’s properties (they are strings) match any of the weaknesses in the monsters list of weaknesses (a list of strings). This is done with a foreach loop. The problem is that the loop is completely skipped over when the damage calculation function is called, making me think that the loop isn’t written correctly, but I’m certain it is, and the program compiles and runs just fine otherwise.
Any sage wisdom you can bestow upon me would be greatly appreciated and, once again, thank you for this incredible tutorial.
Is the datatype for the list of abilities BindingList, or List ? BindingList has some built-in notification, to update the UI. List does not have those.
For the weaknesses, you might be able to use the Visual Studio debugger, to see why the loop is being skipped over. If you are not familiar with the debugger, here is a lesson on how to use it : https://www.scottlilly.com/how-to-use-the-visual-studio-2013-debugger/. If that doesn’t help you find the source of the problem, can you upload the current version of your solution? I might not be able to look at for a couple of days. But, I will try, as soon as I finish up a couple things that are on a deadline.
Hi Scott,
I’m having a bit on an issue after this lesson and that is when I open the game the cboWeapons and cboPotions are showing absolutely nothing. On top of that clicking on the comboboxes is suddenly allowing the keyboard to type into it as well.
Cheers.
Hi Elijah,
Can you upload your solution (including the directories under it, and all the files in those directories) to GitHub or Dropbox, so I can look at it?
Hello! I don’t know do you still follow the comments in here but i need help about something. I created a reset option,
private void ResetThePlayer()
{
Player newPlayer = Player.CreateDefaultPlayer();
cboWeapons.DataSource = newPlayer.Weapons;
cboPotions.DataSource = newPlayer.Potions;
_player = newPlayer;
cboWeapons.DataSource = _player.Weapons;
cboPotions.DataSource = _player.Potions;
MoveTo(World.LocationByID(World.LOCATION_ID_HOME));
rtbMessages.Clear();
lblHitPoints.DataBindings.Clear();
lblGold.DataBindings.Clear();
lblExperience.DataBindings.Clear();
lblLevel.DataBindings.Clear();
lblDeathCounter.DataBindings.Clear();
lblHitPoints.DataBindings.Add(“Text”, _player, “CurrentHitPoints”);
lblGold.DataBindings.Add(“Text”, _player, “Gold”);
lblExperience.DataBindings.Add(“Text”, _player, “ExperiencePoints”);
lblLevel.DataBindings.Add(“Text”, _player, “Level”);
lblDeathCounter.DataBindings.Add(“Text”, _player, “DeathCounter”);
dgvInventory.DataSource = _player.Inventory;
dgvQuests.DataSource = _player.Quests;
cboWeapons.DataSource = newPlayer.Weapons;
cboPotions.DataSource = newPlayer.Potions;
}
after I reset the game, comboboxes stop seeing new weapons,potions. I only see the default ones. Is there something like databindings.clear() function to make a new connection?
cboWeapons.DataSource = newPlayer.Weapons;
cboPotions.DataSource = newPlayer.Potions;
These codes just makes comboboxes default. Then never adds new items.
Hi Aras,
I tried sending you an email. The Dropbox link you sent me did not have all the files. It was missing the .sln file and some of the other classes. If you can upload the missing files, I can look at the program for you.
Hello Scott,
i just completed this lesson and i started my game.
If i fight a monster and use my potion, the combobox and the use button disappear as it should. But if i kill the monster, it reappears. If i try to use a potion now, the program crashes.
I get a System.NullReferenceException “Potion was null” in Line 366, where we add the healing amount to the player’s current hit points.
// Add healing amount to the player’s current hit points
_player.CurrentHitPoints = (_player.CurrentHitPoints + potion.AmountToHeal);
Do you have an idea, where my problem lies or do you need my code?
Thanks!
Hello Scott,
i just wrote a comment because i tried using a potion, even though i had none available.
I just checked my code again and realised, we even wrote a comment, that we might want to raise an error for this situation.
I could try catching the exception to stop the program from crashing, right?
Awesome tutorial!
Hey, it’s me again..
I figured it out myself, i had a mistake in the MoveTo() function.
It works perfectly fine now, sorry for bothering.
foreach(LootItem lootItem in standardMonster.LootTable)
{
_currentMonster.LootTable.Add(lootItem);
}
cboWeapons.Visible = _player.Weapons.Any();
cboPotions.Visible = _player.Potions.Any();
btnUseWeapon.Visible = _player.Weapons.Any();
btnUsePotion.Visible = _player.Potions.Any();
}
else
{
_currentMonster = null;
cboWeapons.Visible = false;
cboPotions.Visible = false;
btnUseWeapon.Visible = false;
btnUsePotion.Visible = false;
}
I mixed up the declarations.
Thanks again for creating such a great program!
Hi Ben,
You’re welcome. It’s great that you figured it out. Sometimes, that is what I do most of my workday – find the source of bugs and strange behavior.