The last feature took several lessons to complete. So, in this lesson, we’ll do a small feature we can add quickly – using keyboard input for player actions.
There are several ways to detect key presses and have them cause an action. However, many of them are more complex than we really need right now. I’ll use a different technique – partly because I also want introduce dictionaries, actions, and delegates.
Step 1: Modify WPFUI\MainWindow.xaml
When the user presses a key, it fires the KeyDown event. This is like the Click event that fires when the user clicks on a button.
In the opening Window section, add line 11 (below) to tell the program the function to run on the KeyDown event.
MainWindow.xaml (lines 1-11)
<Window x:Class="WPFUI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Engine.ViewModels;assembly=Engine"
d:DataContext="{d:DesignInstance viewModels:GameSession}"
mc:Ignorable="d"
FontSize="11pt"
Title="Scott's Awesome Game" Height="768" Width="1024"
KeyDown="MainWindow_OnKeyDown">
Step 2: Modify WPFUI\MainWindow.xaml.cs
The first thing to do is add some new “using” statements (lines 1, 2, and 5). These are for the new datatypes we’re using in this lesson.
Next, create a new private variable to store the key that was pressed and the action to perform for that key (lines 18-19).
This line uses three new things: Dictionary, Key, and Action.
A dictionary is like a collection that holds two values: the key and the value. Think of it like a real-life dictionary. It has a word (the key) and the word’s definition (the value). In a dictionary, you cannot have two values with the same keys – each key must be unique.
In the sample code below, I created a dictionary that has an integer for the key and a string for the value. I added three entries – the numbers from 1 to 3, with their text names.
The “result” variable in this code would hold the value “two”. “_integerNames[2]” says to find the entry where the key is 2 and return it’s value – in this case, the string “two”.
Dictionary<int, string> _integerNames = new Dictionary<int, string>();
_integerNames.Add(1, "one");
_integerNames.Add(2, "two");
_integerNames.Add(3, "three");
string result = _integerNames[2];
In the Dictionary we’re creating, the key (dictionary index) will be the key (keypress from the keyboard) the user pressed.
We’ll use W, A, S, and D for movement (North, West, South, and East). “Z” is attack the current monster, and “C” is to consume the current consumable item.
Instead of using a string for the dictionary entry’s key (for example, “W”), we’ll use a Key, which is an enum. This lets us see if the user also held down shift, control, or alt while pressing a letter or number.
For the dictionary entry’s value, we are going to store an Action. An Action is a pointer to a function to run. This is called a “delegate”. I’ll talk about that more in a minute.
In the constructor (line 25) call the new InitializeUserInputActions() function.
Add the InitializeUserInputActions() function on lines 82-90. This is where we populate the dictionary with the keys and the functions to run when the key is pressed.
To tell the program what function we want as the dictionary entry’s value (the delegate I mentioned), we use code like: “() => _gameSession.MoveNorth()”. That’s a pointer to the function we want to run when the uses presses the “w” key.
The first part “()” defines the parameters we want to pass into the delegate. Because the functions we’re using don’t require any parameters, we’ll use the empty parentheses.
Let’s say we wanted to pass two parameters into our delegate functions: an integer and a string. Then, we would have needed to define the dictionary variable like this:
private readonly Dictionary<Key, Action<int, string>> _userInputActions = new Dictionary<Key, Action<int, string>>();
In the dictionary definition, the “Action<int, string>” says the Action will accept two parameters: an integer and a string.
The delegate code would look like this:
_userInputActions.Add(Key.W, (x, y) => _gameSession.MoveNorth());
The “(x, y)” is for the two parameters we’re passing into the Action. Of course, we’d only do this if we needed to use the parameters. So, we’d probably only use it if MoveNorth required an integer and string parameter. Then, the code would look like:
_userInputActions.Add(Key.W, (x, y) => _gameSession.MoveNorth(x, y));
On lines 92-98 is the function to handle the keypress (the one from line 11 of MainWindow.xaml). It looks at the eventargs parameter “e”, gets the Key that was pressed, and checks to see if the dictionary has an entry whose key is the key that was pressed.
If it does, if gets the dictionary entry’s value “_userInputActions[e.Key]” and runs the function by calling Invoke().
If we were passing parameter values into the delegate, we would need to call “Invoke(x, y)”.
MainWindow.xaml.cs
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Input;
using Engine.EventArgs;
using Engine.Models;
using Engine.ViewModels;
namespace WPFUI
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private readonly GameSession _gameSession = new GameSession();
private readonly Dictionary<Key, Action> _userInputActions =
new Dictionary<Key, Action>();
public MainWindow()
{
InitializeComponent();
InitializeUserInputActions();
_gameSession.OnMessageRaised += OnGameMessageRaised;
DataContext = _gameSession;
}
private void OnClick_MoveNorth(object sender, RoutedEventArgs e)
{
_gameSession.MoveNorth();
}
private void OnClick_MoveWest(object sender, RoutedEventArgs e)
{
_gameSession.MoveWest();
}
private void OnClick_MoveEast(object sender, RoutedEventArgs e)
{
_gameSession.MoveEast();
}
private void OnClick_MoveSouth(object sender, RoutedEventArgs e)
{
_gameSession.MoveSouth();
}
private void OnClick_AttackMonster(object sender, RoutedEventArgs e)
{
_gameSession.AttackCurrentMonster();
}
private void OnClick_UseCurrentConsumable(object sender, RoutedEventArgs e)
{
_gameSession.UseCurrentConsumable();
}
private void OnGameMessageRaised(object sender, GameMessageEventArgs e)
{
GameMessages.Document.Blocks.Add(new Paragraph(new Run(e.Message)));
GameMessages.ScrollToEnd();
}
private void OnClick_DisplayTradeScreen(object sender, RoutedEventArgs e)
{
TradeScreen tradeScreen = new TradeScreen();
tradeScreen.Owner = this;
tradeScreen.DataContext = _gameSession;
tradeScreen.ShowDialog();
}
private void OnClick_Craft(object sender, RoutedEventArgs e)
{
Recipe recipe = ((FrameworkElement)sender).DataContext as Recipe;
_gameSession.CraftItemUsing(recipe);
}
private void InitializeUserInputActions()
{
_userInputActions.Add(Key.W, () => _gameSession.MoveNorth());
_userInputActions.Add(Key.A, () => _gameSession.MoveWest());
_userInputActions.Add(Key.S, () => _gameSession.MoveSouth());
_userInputActions.Add(Key.D, () => _gameSession.MoveEast());
_userInputActions.Add(Key.Z, () => _gameSession.AttackCurrentMonster());
_userInputActions.Add(Key.C, () => _gameSession.UseCurrentConsumable());
}
private void MainWindow_OnKeyDown(object sender, KeyEventArgs e)
{
if(_userInputActions.ContainsKey(e.Key))
{
_userInputActions[e.Key].Invoke();
}
}
}
}
Step 3: Modify Engine\ViewModels\GameSession.cs
Before making these changes, the only way the user could call AttackCurrentMonster was by clicking on the “Use” button for the weapon combobox – which was hidden if the location did not have a monster.
However, after these changes, the user can press the space bar at any time – even if they are at a location without a monster. So, we need to add some “guard” code to AttackCurrentMonster and UseCurrentConsumable, to only run the code if there is a value in CurrentMonster or CurrentPlayer.CurrentConsumable.
We already have this for the movement functions. They each check to see if HasLocationTo<direction> is true, before trying to move to a new location.
Add lines 250-253 to the beginning of AttackCurrentMonster. If CurrentMonster is null, we return from the function immediately. This technique is sometimes called “early exit” – under a certain condition, we exit the function early.
Change UseCurrentConsumable to the code below. We could do the same “early exit” as AttackCurrentMonster. But, because there is only one line in the function, I just surrounded it with an “if” statement.
GameSession.cs (lines 248-280)
public void AttackCurrentMonster()
{
if(CurrentMonster == null)
{
return;
}
if(CurrentPlayer.CurrentWeapon == null)
{
RaiseMessage("You must select a weapon, to attack.");
return;
}
CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
if(CurrentMonster.IsDead)
{
// Get another monster to fight
GetMonsterAtLocation();
}
else
{
CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
}
}
public void UseCurrentConsumable()
{
if(CurrentPlayer.CurrentConsumable != null)
{
CurrentPlayer.UseCurrentConsumable();
}
}
Step 4: Test the program
NEXT LESSON: Lesson 13.2: More keyboard actions (and fixes)
PREVIOUS LESSON: Lesson 12.8: Crafting items with recipes