Press "Enter" to skip to content

Lesson 07.3: Sending Messages from the ViewModel to the View

When the player fights monsters, we will need to display many messages in the UI – if the player hits the monster, how many hit points of damage the player does, how many points of damage the monster does, etc.

In this lesson, we’re going to create a way to send messages from the GameSession (ViewModel), to the UI (View). We’ll do this by having the ViewModel “raise an event”, and have the View “subscribe” to that event.

NOTE: This technique of have an object raise an event is known as publish/subscribe, or the Observer design pattern.

Step 1: Edit \WPFUI\MainWindow.xaml

On the screen, in the left part of the Gameplay section (upper-right quadrant), we have space for the game messages. We’ll add a RichTextBox control, surrounded by a border. This is at lines 76-91, in the source code below.

A RichTextBox control is like a small word processor. We can add text to it, and modify how the text is displayed (size, color, if it is bolded, etc.). We’ll give it an x:Name attribute. This lets us access it through code, in the MainWindow.xaml.cs file.

The RichTextBox control has default formatting, and we want to override the space it adds between each paragraph. Normally, it has a large margin. Because we are only going to display our game messages one line at a time, we want to have a margin of 0 – without any extra space between the messages.

MainWindow.xaml
<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">
    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="225"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="250"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <!-- Menu -->
        <Label Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Content="Menu" Background="AliceBlue"/>
        <!-- Player stats -->
        <Grid Grid.Row="1" Grid.Column="0" Background="Aquamarine">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            
            <Label Grid.Row="0" Grid.Column="0" Content="Name:"/>
            <Label Grid.Row="0" Grid.Column="1" Content="{Binding CurrentPlayer.Name}"/>
            <Label Grid.Row="1" Grid.Column="0" Content="Class:"/>
            <Label Grid.Row="1" Grid.Column="1" Content="{Binding CurrentPlayer.CharacterClass}"/>
            <Label Grid.Row="2" Grid.Column="0" Content="Hit points:"/>
            <Label Grid.Row="2" Grid.Column="1" Content="{Binding CurrentPlayer.HitPoints}"/>
            <Label Grid.Row="3" Grid.Column="0" Content="Gold:"/>
            <Label Grid.Row="3" Grid.Column="1" Content="{Binding CurrentPlayer.Gold}"/>
            <Label Grid.Row="4" Grid.Column="0" Content="XP:"/>
            <Label Grid.Row="4" Grid.Column="1" Content="{Binding CurrentPlayer.ExperiencePoints}"/>
            <Label Grid.Row="5" Grid.Column="0" Content="Level:"/>
            <Label Grid.Row="5" Grid.Column="1" Content="{Binding CurrentPlayer.Level}"/>
        </Grid>
        <!-- Gameplay -->
        <Grid Grid.Row="1" Grid.Column="1"
              Background="Beige">
            <Grid.RowDefinitions>
                <RowDefinition Height="*"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>
            
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="3*"/>
                <ColumnDefinition Width="2*"/>
            </Grid.ColumnDefinitions>
            
            <!-- Game Messages -->
            <Border Grid.Row="0" Grid.Column="0"
                    Grid.RowSpan="2"
                    BorderBrush="Gainsboro"
                    BorderThickness="1">
                <RichTextBox x:Name="GameMessages"
                             Background="Beige"
                             VerticalScrollBarVisibility="Auto">
                    <RichTextBox.Resources>
                        <Style TargetType="{x:Type Paragraph}">
                            <Setter Property="Margin" Value="0"/>
                        </Style>
                    </RichTextBox.Resources>
                </RichTextBox>
                
            </Border>
            
            <!-- Location information -->
            <Border Grid.Row="0" Grid.Column="1"
                    BorderBrush="Gainsboro"
                    BorderThickness="1">
                
                <Grid Margin="3">
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                        <RowDefinition Height="Auto"/>
                    </Grid.RowDefinitions>
                    
                    <TextBlock Grid.Row="0"
                               HorizontalAlignment="Center"
                               Text="{Binding CurrentLocation.Name}"/>
                    
                    <Image Grid.Row="1"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Height="125"
                           Width="125"
                           Source="{Binding CurrentLocation.ImageName}"/>
                    
                    <TextBlock Grid.Row="2"
                               HorizontalAlignment="Center"
                               Text="{Binding CurrentLocation.Description}"
                               TextWrapping="Wrap"/>
                </Grid>
                
            </Border>
            
            <!-- Monster information -->
            <Border Grid.Row="1" Grid.Column="1"
                    BorderBrush="Gainsboro"
                    BorderThickness="1">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto" />
                        <RowDefinition Height="*" />
                        <RowDefinition Height="Auto" />
                    </Grid.RowDefinitions>
                    <TextBlock Grid.Row="0"
                               HorizontalAlignment="Center"
                               Height="Auto"
                               Text="{Binding CurrentMonster.Name}" />
                    <Image Grid.Row="1"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Height="125"
                           Width="125"
                           Source="{Binding CurrentMonster.ImageName}" />
                    <StackPanel Grid.Row="2"
                                Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
                                HorizontalAlignment="Center"
                                Orientation="Horizontal">
                        <TextBlock>Current Hit Points:</TextBlock>
                        <TextBlock Text="{Binding CurrentMonster.HitPoints}" />
                    </StackPanel>
                </Grid>
            </Border>
        </Grid>
        <!-- Inventory and Quests -->
        <Grid Grid.Row="2" Grid.Column="0"
              Background="BurlyWood">
        
            <TabControl>
                <TabItem Header="Inventory">
                    <DataGrid ItemsSource="{Binding CurrentPlayer.Inventory}"
                              AutoGenerateColumns="False"
                              HeadersVisibility="Column">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Description"
                                                Binding="{Binding Name}"
                                                Width="*"/>
                            <DataGridTextColumn Header="Price"
                                                Binding="{Binding Price}"
                                                Width="Auto"/>
                        </DataGrid.Columns>
                    </DataGrid>
                </TabItem>
                <TabItem Header="Quests">
                    <DataGrid ItemsSource="{Binding CurrentPlayer.Quests}"
                              AutoGenerateColumns="False"
                              HeadersVisibility="Column">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Name"
                                                Binding="{Binding PlayerQuest.Name}"
                                                Width="*"/>
                            <DataGridTextColumn Header="Done?"
                                                Binding="{Binding IsCompleted}"
                                                Width="Auto"/>
                        </DataGrid.Columns>
                    </DataGrid>
                </TabItem>
            </TabControl>
        </Grid>
        <!-- Action controls -->
        <Grid Grid.Row="2" Grid.Column="1"
              Background="Lavender">
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="255" />
            </Grid.ColumnDefinitions>
            
            <!-- Movement Controls -->
            <Grid Grid.Row="0" Grid.Column="1">
                <Grid.RowDefinitions>
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <Button Grid.Row="0" Grid.Column="1" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveNorth"
                        Visibility="{Binding HasLocationToNorth, Converter={StaticResource BooleanToVisibility}}"
                        Content="North"/>
                <Button Grid.Row="1" Grid.Column="0" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveWest"
                        Visibility="{Binding HasLocationToWest, Converter={StaticResource BooleanToVisibility}}"
                        Content="West"/>
                <Button Grid.Row="1" Grid.Column="2" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveEast"
                        Visibility="{Binding HasLocationToEast, Converter={StaticResource BooleanToVisibility}}"
                        Content="East"/>
                <Button Grid.Row="2" Grid.Column="1" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveSouth"
                        Visibility="{Binding HasLocationToSouth, Converter={StaticResource BooleanToVisibility}}"
                        Content="South"/>
            </Grid>
        </Grid>
    </Grid>
</Window>

Step 2: Create a new “EventArgs” folder, in the Engine project, and create a new class in that folder – GameMessageEventArgs.cs

The ViewModel is going to communicate with the View by “raising events”. This will let the View know that something happened. But, the View also needs to know what text to display on the screen.

To send additional information with an event, you use an “event argument”. We’re going to create a custom event argument that will hold the text to display in the View.

To create a custom event argument, you only need to create a new class and make it inherit from System.EventArgs. Our custom event argument class will be GameMessageEventArgs.

Next, we’ll give the class a Message property, which will hold the message text to display.

Finally, we’ll add a constructor that accept the message as a parameter and sets the Message property’s value.

So, when we raise the message event, we’ll instantiate a new GameMessageEventArgs object, with the message text, and pass that object by the event.

GameMessageEventArgs.cs
namespace Engine.EventArgs
{
    public class GameMessageEventArgs : System.EventArgs
    {
        public string Message { get; private set; }
        public GameMessageEventArgs(string message)
        {
            Message = message;
        }
    }
}

Step 3: Edit \Engine\ViewModels\GameSession.cs

Now that we have a class to hold the message, we can modify the GameSession class to send out messages for the View to display.

ThThe first step is to create a public EventHandler<GameMessageEventArgs> named OnMessageRaised (see line 11). This is how the View “subscribes” to the event – like you might subscribe to a weather alerts on a smartphone. The “<GameMessageEventArgs>” tells the subscribers what type of event arguments to look for, so they can use the data passed in the event.

Next, we will create the RaiseMessage function (line 160). When the ViewModel wants to raise the event, it will call this function.

The code checks if OnMessageRaised has any subscribers (it will be “null”, if there are no subscribers). If there are subscribers, it will invoke the event, passing a new GameMessageEventArgs object that has the desired message text.

To test the event, we’ll add a message when the player moves to a new location and encounters a monster.

We can do this by modifying the CurrentMonster setter (lines 49-53). If the CurrentMonster is not null (so, there is a Monster object in CurrentMonster), we will call the RaiseMessage function with an empty string (to put a blank line in the UI). Then, we’ll call RaiseMessage again, with a message that tells the player what type of monster is at the location.

So, the flow will work like this:

  • The player moves to a new location
  • The location has a monster
  • CurrentMonster is set to the location’s monster
  • The CurrentMonster setter call RaiseMessage
  • RaiseMessage sends the GameMessageEventArgs object to any objects subscribed to OnMessageRaised
GameSession.cs
using System;
using System.Linq;
using Engine.EventArgs;
using Engine.Factories;
using Engine.Models;
namespace Engine.ViewModels
{
    public class GameSession : BaseNotificationClass
    {
        public event EventHandler<GameMessageEventArgs> OnMessageRaised;
        #region Properties
        private Location _currentLocation;
        private Monster _currentMonster;
        public World CurrentWorld { get; set; }
        public Player CurrentPlayer { get; set; }
        public Location CurrentLocation
        {
            get { return _currentLocation; }
            set
            {
                _currentLocation = value;
                OnPropertyChanged(nameof(CurrentLocation));
                OnPropertyChanged(nameof(HasLocationToNorth));
                OnPropertyChanged(nameof(HasLocationToEast));
                OnPropertyChanged(nameof(HasLocationToWest));
                OnPropertyChanged(nameof(HasLocationToSouth));
                GivePlayerQuestsAtLocation();
                GetMonsterAtLocation();
            }
        }
        public Monster CurrentMonster
        {
            get { return _currentMonster; }
            set
            {
                _currentMonster = value;
                OnPropertyChanged(nameof(CurrentMonster));
                OnPropertyChanged(nameof(HasMonster));
                if (CurrentMonster != null)
                {
                    RaiseMessage("");
                    RaiseMessage($"You see a {CurrentMonster.Name} here!");
                }
            }
        }
        public Weapon CurrentWeapon { get; set; }
        public bool HasLocationToNorth
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;
            }
        }
        public bool HasLocationToEast
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;
            }
        }
        public bool HasLocationToSouth
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;
            }
        }
        public bool HasLocationToWest
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;
            }
        }
        public bool HasMonster => CurrentMonster != null;
        #endregion
        public GameSession()
        {
            CurrentPlayer = new Player
                            {
                                Name = "Scott",
                                CharacterClass = "Fighter",
                                HitPoints = 10,
                                Gold = 1000000,
                                ExperiencePoints = 0,
                                Level = 1
                            };
            CurrentWorld = WorldFactory.CreateWorld();
            CurrentLocation = CurrentWorld.LocationAt(0, 0);
        }
        public void MoveNorth()
        {
            if(HasLocationToNorth)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1);
            }
        }
        public void MoveEast()
        {
            if(HasLocationToEast)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate);
            }
        }
        public void MoveSouth()
        {
            if(HasLocationToSouth)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1);
            }
        }
        public void MoveWest()
        {
            if(HasLocationToWest)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate);
            }
        }
        private void GivePlayerQuestsAtLocation()
        {
            foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                if(!CurrentPlayer.Quests.Any(q => q.PlayerQuest.ID == quest.ID))
                {
                    CurrentPlayer.Quests.Add(new QuestStatus(quest));
                }
            }
        }
        private void GetMonsterAtLocation()
        {
            CurrentMonster = CurrentLocation.GetMonster();
        }
        private void RaiseMessage(string message)
        {
            OnMessageRaised?.Invoke(this, new GameMessageEventArgs(message));
        }
    }
}

Step 4: Edit \WPFUI\MainWindow.xaml.cs

Now that the ViewModel can send messages, we need the View to watch for these messages – “subscribe” to the eventhandler.

In the constructor (line 21), we have the View subscribe to the eventhandler. This line says, “when _gameSession raises an OnMessageRaised event, run the OnGameMessageRaised function (the View’s function to do something, when it sees this event raised)”.

On line 46, we have the new OnGameMessageRaised function. This automatically has two parameters: the sender (the object that raised the event), and the event arguments (in this case, its datatype is GameMessageEventArgs, since we defined that in GameSession, with “EventHandler<GameMessageEventArgs>”.

When this function notices an event was raised, it will take the GameMessageEventArgs object, get the value of its Message property, format it (adding a “run” of text to a new paragraph, and adding it to the RichTextBox document), and scroll to the bottom of the RichTextBox (so the user can always see the latest message.

MainWindow.xaml.cs
using System.Windows;
using System.Windows.Documents;
using Engine.EventArgs;
using Engine.ViewModels;
namespace WPFUI
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private GameSession _gameSession;
        public MainWindow()
        {
            InitializeComponent();
            _gameSession = new GameSession();
            _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 OnGameMessageRaised(object sender, GameMessageEventArgs e)
        {
            GameMessages.Document.Blocks.Add(new Paragraph(new Run(e.Message)));
            GameMessages.ScrollToEnd();
        }
    }
}

Step 5: Test the game

Run the game, and move to the locations that have monsters. When you see the image of a monster, you should now see a message in the RichTextBox that tells the player what type of monster they encountered.

In the next lesson, we will let the player fight the monster.

NEXT LESSON: Lesson 07.4: Monster Combat

PREVIOUS LESSON: Lesson 07.2: Adding Monsters to Locations

9 Comments

  1. Eric Woodworth
    Eric Woodworth 2021-10-08

    Is there a way to send everything minus the player info, inventory, and quests to the main view screen? Like in those old school MUDs?

    • SOSCSRPG
      SOSCSRPG 2021-10-09

      Hi Eric,

      You could make changes in the GameSession class and add more calls to RaiseMessage(). For example, in the Move functions, update the CurrentLocation property PLUS call RaiseMessage() to display the current location’s information.

      Let me know if that isn’t clear, and if you want an example.

  2. James
    James 2021-12-01

    Scott,

    Noticed an extra line when the game enters its first entry into the RichTextBox.
    my fix was to add a private static bool variable initialized to true to the MainWindow class in MainWindow.xaml.cs. In the OnGameMessageRaised method I check if true. if true I set the bool to false and do GameMessages.Document.Blocks.Clear(). This is only done on its first call to this method. I’m sure this is a hack and there is a better way to deal with the document first block? Best way to see this is to comment out RaiseMessage(“”) from CurrentMonster method in GameSession.cs.

    Thanks, James

    • SOSCSRPG
      SOSCSRPG 2021-12-01

      Hi James,

      I’d consider changing GameMessageEventArgs to have a list of strings for its Message property, instead of the current string datatype. Then, in the OnGameMessageRaised function, put all those lines into a single document block, then add a blank line after the block. I’m not sure that would work, since I haven’t done much with RichTextBox, but that sounds like it might work.

  3. Old Jobobo
    Old Jobobo 2022-12-14

    After completing the last three monster sections and getting to the first test here, no compiler errors but I do not see anything in the monster box. I see the new border but nothing else populates. You should have access to my git repo, if you have time to help figure out what I did wrong I would be very grateful.

    • SOSCSRPG
      SOSCSRPG 2022-12-14

      I should be able to check it out tomorrow (Wednesday) night and let you know what I see.

    • SOSCSRPG
      SOSCSRPG 2022-12-14

      Check the “set” for the CurrentLocation. After raising the property changed notifications, it gives the player the quest at the location, but it doesn’t call GetMonsterAtLocation(), so The CurrentMonster property is never set with a monster object.

      • Old Jobobo
        Old Jobobo 2022-12-15

        Thank you so much, As a thank you if you look at the images in my repo there are a number of images I did in Midjourney to replace your “super awesome” art assets 🙂 if you want to use them and replace your assets in the lesson feel free.

        • SOSCSRPG
          SOSCSRPG 2022-12-15

          You’re welcome – even though I now feel sad about my awesome art. 🙂 Maybe I need to have AI make some monster images.

Leave a Reply

Your email address will not be published. Required fields are marked *