Let’s work on the Character Creation screen, so users can create characters with random attributes and with race modifiers applied.
Step 1: Modify \Engine\Models\PlayerAttribute.cs
We’ll want the character creation screen to show random values for each attribute and show the modified attribute values – if your game is set up with player Races and PlayerAttributeModifiers.
The player will be able to select the race from a dropdown and change it in the UI. So, we need to change PlayerAttribute to inherit from BaseNotificationClass and change the ModifiedValue property to use a backing variable and call OnPropertyChanged, to refresh the value in the UI.
PlayerAttribute.cs
using Engine.Services;
namespace Engine.Models
{
public class PlayerAttribute : BaseNotificationClass
{
private int _modifiedValue;
public string Key { get; }
public string DisplayName { get; }
public string DiceNotation { get; }
public int BaseValue { get; set; }
public int ModifiedValue
{
get => _modifiedValue;
set
{
_modifiedValue = value;
OnPropertyChanged();
}
}
// Constructor that will use DiceService to create a BaseValue.
// The constructor this calls will put that same value into BaseValue and ModifiedValue
public PlayerAttribute(string key, string displayName, string diceNotation)
: this(key, displayName, diceNotation, DiceService.Instance.Roll(diceNotation).Value)
{
}
// Constructor that takes a baseValue and also uses it for modifiedValue,
// for when we're creating a new attribute
public PlayerAttribute(string key, string displayName, string diceNotation,
int baseValue) :
this(key, displayName, diceNotation, baseValue, baseValue)
{
}
// This constructor is eventually called by the others,
// or used when reading a Player's attributes from a saved game file.
public PlayerAttribute(string key, string displayName, string diceNotation,
int baseValue, int modifiedValue)
{
Key = key;
DisplayName = displayName;
DiceNotation = diceNotation;
BaseValue = baseValue;
ModifiedValue = modifiedValue;
}
public void ReRoll()
{
BaseValue = DiceService.Instance.Roll(DiceNotation).Value;
ModifiedValue = BaseValue;
}
}
}
Step 2: Modify \Engine\ViewModels\CharacterCreationViewModel.cs
Now that we’re doing something in the Character Creation window, we need to build its ViewModel.
We’ll start by inheriting from BaseNotificationClass and changing the SelectedRace property to use a backing variable and raise PropertyChanged notifications.
I changed the PlayerAttributes property from List to ObservableCollection since we’re binding to the list. The collection shouldn’t change after the first time it’s populated. But I like to bind to ObservableCollections in case it does ever need to change.
On lines 28-32, there are two new expression-bodied Boolean properties that let us know if the GameDetails contains Races and RaceAttrbitueModifiers. Those properties will be used to hide or show controls in the window. If there aren’t Races, we don’t need to show the dropdown. If there aren’t Races, or any Races that have RaceAttributeModifiers, we don’t need to show the PlayerAttribute’s ModifiedValue – it will always be the BaseValue.
On lines 34-44 is a new constructor that populates the GameDetails property from the GameDetails.json file, sets the SelectedRace to the first available Race (if there are Races in GameDetails), and rolls a new character.
The RollNewCharacter function on line 46 re-populates the PlayerAttributes property with new random values, then applies the attribute modifiers. This is called from the constructor, to give us an initial set of values, or if the user clicks the “Roll new player” button.
ApplyAttributeModifiers, on line 59, checks each PlayerAttributes, sees if there is a modifier for the selected race and attribute, and applies it (if there is one). This is in a separate function so we can apply different modifiers if the user selects a different race from the dropdown. When that happens, we’ll use the same PlayerAttributes, but just apply the new race’s modifiers.
Finally, the GetPlayer function creates a Player object with the values from the PlayerAttributes. The next lesson is going to modify the Player object, getting rid of the Dexterity property, and adding a property that’s a List of PlayerAttributes. This function is used when the user clicks the “Use this player” button and starts the game.
CharacterCreationViewModel.cs
using System.Collections.ObjectModel;
using System.Linq;
using Engine.Models;
using Engine.Services;
namespace Engine.ViewModels
{
public class CharacterCreationViewModel : BaseNotificationClass
{
private Race _selectedRace;
public GameDetails GameDetails { get; }
public Race SelectedRace
{
get => _selectedRace;
set
{
_selectedRace = value;
OnPropertyChanged();
}
}
public string Name { get; set; }
public ObservableCollection<PlayerAttribute> PlayerAttributes { get; set; } =
new ObservableCollection<PlayerAttribute>();
public bool HasRaces =>
GameDetails.Races.Any();
public bool HasRaceAttributeModifiers =>
HasRaces && GameDetails.Races.Any(r => r.PlayerAttributeModifiers.Any());
public CharacterCreationViewModel()
{
GameDetails = GameDetailsService.ReadGameDetails();
if(HasRaces)
{
SelectedRace = GameDetails.Races.First();
}
RollNewCharacter();
}
public void RollNewCharacter()
{
PlayerAttributes.Clear();
foreach(PlayerAttribute playerAttribute in GameDetails.PlayerAttributes)
{
playerAttribute.ReRoll();
PlayerAttributes.Add(playerAttribute);
}
ApplyAttributeModifiers();
}
public void ApplyAttributeModifiers()
{
foreach(PlayerAttribute playerAttribute in PlayerAttributes)
{
var attributeRaceModifier =
SelectedRace.PlayerAttributeModifiers
.FirstOrDefault(pam => pam.AttributeKey.Equals(playerAttribute.Key));
playerAttribute.ModifiedValue =
playerAttribute.BaseValue + (attributeRaceModifier?.Modifier ?? 0);
}
}
public Player GetPlayer()
{
return new Player(Name, "Fighter", 0, 10, 10,
PlayerAttributes.FirstOrDefault(pa => pa.Key.Equals("DEX"))?.ModifiedValue ?? 13, 10);
}
}
}
Step 3: Modify \WPFUI\App.xaml
On line 9, I added a BooleanToVisibilityConverter, so we can hide Race-related UI controls if the game isn’t set up for player races.
App.xaml
<Application x:Class="WPFUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:WPFUI.CustomConverters"
DispatcherUnhandledException="App_OnDispatcherUnhandledException"
StartupUri="Startup.xaml">
<Application.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
<converters:FileToBitmapConverter x:Key="FileToBitmapConverter"/>
</Application.Resources>
</Application>
Step 4: Create new window \WPFUI\CharacterCreation.xaml and CharacterCreation.xaml.cs
There isn’t much new in the XAML, other than the Visibility property to hide UI controls if the GameDetails.json doesn’t include player races.
On line 51, we do set the SelectionChanged attribute to run the Race_OnSelectionChanged function when the player selects a different value from the dropdown.
In CharacterCreation.xaml.cs, we’ve changed some of the functions to roll a new character and apply race modifiers, by calling the functions in the ViewModel.
On line 26 we pass a Player object into the MainWindow constructor, based on the current player information in the ViewModel.
This window could use some improvements. Right now, we don’t validate that a player name was entered, and we could improve the looks of it. But, I want to finish up the game logic before we do too much work on the UI.
CharacterCreation.xaml
<Window x:Class="WPFUI.CharacterCreation"
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:local="clr-namespace:WPFUI"
xmlns:viewModels="clr-namespace:Engine.ViewModels;assembly=Engine"
d:DataContext="{d:DesignInstance viewModels:CharacterCreationViewModel}"
mc:Ignorable="d"
FontSize="11pt"
Title="{Binding GameDetails.Title}" Height="400" Width="400">
<Grid Margin="10,10,10,10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Grid.Row="0" Grid.Column="0"
Grid.ColumnSpan="2"
FontWeight="Bold"
HorizontalAlignment="Center"
Content="Create a new character"/>
<!-- Character creation controls -->
<Label Grid.Row="1" Grid.Column="0"
FontWeight="Bold"
Content="Name:"/>
<TextBox Grid.Row="1" Grid.Column="1"
Width="250"
HorizontalAlignment="Left"
Text="{Binding Name}"/>
<Label Grid.Row="2" Grid.Column="0"
FontWeight="Bold"
Content="Race:"
Visibility="{Binding HasRaces, Converter={StaticResource BooleanToVisibilityConverter}}"/>
<ComboBox Grid.Row="2" Grid.Column="1"
Width="250"
HorizontalAlignment="Left"
ItemsSource="{Binding GameDetails.Races}"
DisplayMemberPath="DisplayName"
SelectedItem="{Binding SelectedRace, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
SelectionChanged="Race_OnSelectionChanged"
Visibility="{Binding HasRaces, Converter={StaticResource BooleanToVisibilityConverter}}"/>
<DataGrid Grid.Row="3" Grid.Column="0"
Grid.ColumnSpan="2"
ItemsSource="{Binding PlayerAttributes}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Attribute"
Binding="{Binding DisplayName}"
Width="*"/>
<DataGridTextColumn Header="Value"
Binding="{Binding BaseValue}"/>
<DataGridTextColumn Header="Modified"
Binding="{Binding ModifiedValue}"
Visibility="{Binding HasRaceAttributeModifiers,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
</DataGrid.Columns>
</DataGrid>
<Grid Grid.Row="4" Grid.Column="0"
Grid.ColumnSpan="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Row="0" Grid.Column="0"
Margin="0,5,0,5"
HorizontalAlignment="Center"
Width="125"
Content="Roll new player"
Click="RandomPlayer_OnClick"/>
<Button Grid.Row="0" Grid.Column="2"
Margin="0,5,0,5"
HorizontalAlignment="Center"
Width="125"
Content="Use this player"
Click="UseThisPlayer_OnClick"/>
</Grid>
</Grid>
</Window>
CharacterCreation.xaml.cs
using System.Windows;
using System.Windows.Controls;
using Engine.ViewModels;
namespace WPFUI
{
public partial class CharacterCreation : Window
{
private CharacterCreationViewModel VM { get; set; }
public CharacterCreation()
{
InitializeComponent();
VM = new CharacterCreationViewModel();
DataContext = VM;
}
private void RandomPlayer_OnClick(object sender, RoutedEventArgs e)
{
VM.RollNewCharacter();
}
private void UseThisPlayer_OnClick(object sender, RoutedEventArgs e)
{
MainWindow mainWindow = new MainWindow(VM.GetPlayer());
mainWindow.Show();
Close();
}
private void Race_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
VM.ApplyAttributeModifiers();
}
}
}
Step 5: Modify \WPFUI\MainWindow.xaml.cs
On lines 36-40, I added a new MainWindow constructor that accepts a Player object (from the CharacterCreation window) and puts it into the GameSesssion’s CurrentPlayer object. This should let us create new random players and see them in the game UI.
MainWindow.xaml.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using Engine.EventArgs;
using Engine.Models;
using Engine.Services;
using Engine.ViewModels;
using Microsoft.Win32;
using WPFUI.Windows;
namespace WPFUI
{
public partial class MainWindow : Window
{
private const string SAVE_GAME_FILE_EXTENSION = "soscsrpg";
private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
private readonly Dictionary<Key, Action> _userInputActions =
new Dictionary<Key, Action>();
private GameSession _gameSession;
public MainWindow()
{
InitializeComponent();
InitializeUserInputActions();
SetActiveGameSessionTo(new GameSession());
}
public MainWindow(Player player) :
this()
{
_gameSession.CurrentPlayer = player;
}
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)
{
if(_gameSession.CurrentTrader != null)
{
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());
_userInputActions.Add(Key.I, () => SetTabFocusTo("InventoryTabItem"));
_userInputActions.Add(Key.Q, () => SetTabFocusTo("QuestsTabItem"));
_userInputActions.Add(Key.R, () => SetTabFocusTo("RecipesTabItem"));
_userInputActions.Add(Key.T, () => OnClick_DisplayTradeScreen(this, new RoutedEventArgs()));
}
private void MainWindow_OnKeyDown(object sender, KeyEventArgs e)
{
if(_userInputActions.ContainsKey(e.Key))
{
_userInputActions[e.Key].Invoke();
}
}
private void SetTabFocusTo(string tabName)
{
foreach(object item in PlayerDataTabControl.Items)
{
if (item is TabItem tabItem)
{
if (tabItem.Name == tabName)
{
tabItem.IsSelected = true;
return;
}
}
}
}
private void SetActiveGameSessionTo(GameSession gameSession)
{
// Unsubscribe from OnMessageRaised, or we will get double messages
_messageBroker.OnMessageRaised -= OnGameMessageRaised;
_gameSession = gameSession;
DataContext = _gameSession;
// Clear out previous game's messages
GameMessages.Document.Blocks.Clear();
_messageBroker.OnMessageRaised += OnGameMessageRaised;
}
private void StartNewGame_OnClick(object sender, RoutedEventArgs e)
{
SetActiveGameSessionTo(new GameSession());
}
private void LoadGame_OnClick(object sender, RoutedEventArgs e)
{
OpenFileDialog openFileDialog =
new OpenFileDialog
{
InitialDirectory = AppDomain.CurrentDomain.BaseDirectory,
Filter = $"Saved games (*.{SAVE_GAME_FILE_EXTENSION})|*.{SAVE_GAME_FILE_EXTENSION}"
};
if(openFileDialog.ShowDialog() == true)
{
SetActiveGameSessionTo(SaveGameService.LoadLastSaveOrCreateNew(openFileDialog.FileName));
}
}
private void SaveGame_OnClick(object sender, RoutedEventArgs e)
{
SaveGame();
}
private void Exit_OnClick(object sender, RoutedEventArgs e)
{
Close();
}
private void MainWindow_OnClosing(object sender, CancelEventArgs e)
{
YesNoWindow message =
new YesNoWindow("Save Game", "Do you want to save your game?");
message.Owner = GetWindow(this);
message.ShowDialog();
if (message.ClickedYes)
{
SaveGame();
}
}
private void SaveGame()
{
SaveFileDialog saveFileDialog =
new SaveFileDialog
{
InitialDirectory = AppDomain.CurrentDomain.BaseDirectory,
Filter = $"Saved games (*.{SAVE_GAME_FILE_EXTENSION})|*.{SAVE_GAME_FILE_EXTENSION}"
};
if (saveFileDialog.ShowDialog() == true)
{
SaveGameService.Save(_gameSession, saveFileDialog.FileName);
}
}
}
}
Step 6: Test the game
Check that the game works and that the randomly-created player’s data shows up on the main game screen.
Next, we’ll modify the Player class to use the PlayerAttributes, remove the individual Dexterity property, and change the CombatService class to use Dexterity from the PlayerAttributes.
Then, we’ll update the save and restore game logic to handle the PlayerAttributes.
NEXT LESSON: Lesson 18.4: Adding Player Attributes to Living Entities
PREVIOUS LESSON: Lesson 18.2: New starting windows and configurable player races
Is it intended that modified attribute values should not be appearing yet?
The modified attribute value should be appearing, based on step 4 CharacterCreation.xaml (lines 68-71). Are they not showing for you?
I struggled a fair bit with this one. I found that it was looping through the function to add attribute modifiers twice at the start, for no reason I could fathom. Also, it seems like it was adding the modifiers on top of each other each time you used the drop down menu to change race (for example, changing from Elf to Orc just cancelled one another out, since they both had opposite effects). I had to write a separate method to remove the attribute modifiers and call it in a couple different places in order to get it working correctly. A bit odd!
I looked over the code. That might be happening because, when the UI populates the Races dropdown, it also “selects” the first option in the dropdown, causing the Race_OnSelectionChanged function to be triggered which calls VM.ApplyAttributeModifiers(). That’s something to watch out for in event-driven programming. Sometimes the events fire off in places, or at times, you don’t really expect them to.