Press "Enter" to skip to content

Lesson 20.1: Create floating inventory canvas

It’s time to work on the UI.

The first thing we’ll do is change some of the sections so we can hide them and position them on the screen wherever the user wants.

We’ll do this by putting a Canvas on the screen, which lets us easily position child controls. For a graphics-heavy game, you would want to use a different technique, like use Unity. But, for this simple game, we can probably use a Canvas.

We’ll start with the Inventory tab.


Link to video on YouTube

Step 1: Create SOSCSRPG.Models\PopupDetails.cs

We’ll use this class for all popups, to identify if they’re visible or hidden, and where they’re located on the screen.

After we convert the other controls (quests, recipes, and player stats) to popups, we’ll add these values to the saved game logic so the show up in the same location when the player re-starts their next game.

PopupDetails.cs

using System.ComponentModel;

namespace SOSCSRPG.Models
{
    public class PopupDetails : INotifyPropertyChanged
    {
        public bool IsVisible { get; set; }
        public int Top { get; set; }
        public int Left { get; set; }
        public int MinHeight { get; set; }
        public int MaxHeight { get; set; }
        public int MinWidth { get; set; }
        public int MaxWidth { get; set; }

        public event PropertyChangedEventHandler PropertyChanged;
    }
}

Step 2: Modify SOSCSRPG.ViewModels\GameSession.cs

On line 93, add a new InventoryDetails property to hold the PopupDetails for the inventory.

In the constructor, beginning on line 128, populate the new InventoryDetails with some default values.

GameSession.cs

using System.ComponentModel;
using System.Linq;
using SOSCSRPG.Services.Factories;
using SOSCSRPG.Models;
using SOSCSRPG.Services;
using Newtonsoft.Json;
using SOSCSRPG.Core;

namespace SOSCSRPG.ViewModels
{
    public class GameSession : INotifyPropertyChanged
    {
        private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();

        #region Properties

        private Player _currentPlayer;
        private Location _currentLocation;
        private Battle _currentBattle;
        private Monster _currentMonster;

        public event PropertyChangedEventHandler PropertyChanged;

        [JsonIgnore]
        public GameDetails GameDetails { get; private set; }

        [JsonIgnore]
        public World CurrentWorld { get; }

        public Player CurrentPlayer
        {
            get => _currentPlayer;
            set
            {
                if(_currentPlayer != null)
                {
                    _currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
                    _currentPlayer.OnKilled -= OnPlayerKilled;
                }

                _currentPlayer = value;

                if(_currentPlayer != null)
                {
                    _currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
                    _currentPlayer.OnKilled += OnPlayerKilled;
                }
            }
        }

        public Location CurrentLocation
        {
            get => _currentLocation;
            set
            {
                _currentLocation = value;

                CompleteQuestsAtLocation();
                GivePlayerQuestsAtLocation();
                CurrentMonster = MonsterFactory.GetMonsterFromLocation(CurrentLocation);

                CurrentTrader = CurrentLocation.TraderHere;
            }
        }

        [JsonIgnore]
        public Monster CurrentMonster
        {
            get => _currentMonster;
            set
            {
                if(_currentBattle != null)
                {
                    _currentBattle.OnCombatVictory -= OnCurrentMonsterKilled;
                    _currentBattle.Dispose();
                    _currentBattle = null;
                }

                _currentMonster = value;

                if(_currentMonster != null)
                {
                    _currentBattle = new Battle(CurrentPlayer, CurrentMonster);

                    _currentBattle.OnCombatVictory += OnCurrentMonsterKilled;
                }
            }
        }

        [JsonIgnore]
        public Trader CurrentTrader { get; private set; }

        public PopupDetails InventoryDetails { get; set; }

        [JsonIgnore]
        public bool HasLocationToNorth =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;

        [JsonIgnore]
        public bool HasLocationToEast =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;

        [JsonIgnore]
        public bool HasLocationToSouth =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;

        [JsonIgnore]
        public bool HasLocationToWest =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;

        [JsonIgnore]
        public bool HasMonster => CurrentMonster != null;

        [JsonIgnore]
        public bool HasTrader => CurrentTrader != null;

        #endregion

        public GameSession(Player player, int xCoordinate, int yCoordinate)
        {
            PopulateGameDetails();

            CurrentWorld = WorldFactory.CreateWorld();
            CurrentPlayer = player;
            CurrentLocation = CurrentWorld.LocationAt(xCoordinate, yCoordinate);

            // Setup popup window properties
            InventoryDetails = new PopupDetails
            {
                IsVisible = false,
                Top = 225,
                Left = 275,
                MinHeight = 75,
                MaxHeight = 175,
                MinWidth = 250,
                MaxWidth = 400
            };
        }

        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 PopulateGameDetails()
        {
            GameDetails = GameDetailsService.ReadGameDetails();
        }

        private void CompleteQuestsAtLocation()
        {
            foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                QuestStatus questToComplete =
                    CurrentPlayer.Quests.FirstOrDefault(q => q.PlayerQuest.ID == quest.ID &&
                                                             !q.IsCompleted);

                if(questToComplete != null)
                {
                    if(CurrentPlayer.Inventory.HasAllTheseItems(quest.ItemsToComplete))
                    {
                        CurrentPlayer.RemoveItemsFromInventory(quest.ItemsToComplete);

                        _messageBroker.RaiseMessage("");
                        _messageBroker.RaiseMessage($"You completed the '{quest.Name}' quest");

                        // Give the player the quest rewards
                        _messageBroker.RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
                        CurrentPlayer.AddExperience(quest.RewardExperiencePoints);

                        _messageBroker.RaiseMessage($"You receive {quest.RewardGold} gold");
                        CurrentPlayer.ReceiveGold(quest.RewardGold);

                        foreach(ItemQuantity itemQuantity in quest.RewardItems)
                        {
                            GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);

                            _messageBroker.RaiseMessage($"You receive a {rewardItem.Name}");
                            CurrentPlayer.AddItemToInventory(rewardItem);
                        }

                        // Mark the Quest as completed
                        questToComplete.IsCompleted = true;
                    }
                }
            }
        }

        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));

                    _messageBroker.RaiseMessage("");
                    _messageBroker.RaiseMessage($"You receive the '{quest.Name}' quest");
                    _messageBroker.RaiseMessage(quest.Description);

                    _messageBroker.RaiseMessage("Return with:");
                    foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
                    {
                        _messageBroker
                            .RaiseMessage($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
                    }

                    _messageBroker.RaiseMessage("And you will receive:");
                    _messageBroker.RaiseMessage($"   {quest.RewardExperiencePoints} experience points");
                    _messageBroker.RaiseMessage($"   {quest.RewardGold} gold");
                    foreach(ItemQuantity itemQuantity in quest.RewardItems)
                    {
                        _messageBroker
                            .RaiseMessage($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
                    }
                }
            }
        }

        public void AttackCurrentMonster()
        {
            _currentBattle?.AttackOpponent();
        }

        public void UseCurrentConsumable()
        {
            if(CurrentPlayer.CurrentConsumable != null)
            {
                if (_currentBattle == null)
                {
                    CurrentPlayer.OnActionPerformed += OnConsumableActionPerformed;
                }

                CurrentPlayer.UseCurrentConsumable();

                if (_currentBattle == null)
                {
                    CurrentPlayer.OnActionPerformed -= OnConsumableActionPerformed;
                }
            }
        }

        private void OnConsumableActionPerformed(object sender, string result)
        {
            _messageBroker.RaiseMessage(result);
        }

        public void CraftItemUsing(Recipe recipe)
        {
            if(CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
            {
                CurrentPlayer.RemoveItemsFromInventory(recipe.Ingredients);

                foreach(ItemQuantity itemQuantity in recipe.OutputItems)
                {
                    for(int i = 0; i < itemQuantity.Quantity; i++)
                    {
                        GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
                        CurrentPlayer.AddItemToInventory(outputItem);
                        _messageBroker.RaiseMessage($"You craft 1 {outputItem.Name}");
                    }
                }
            }
            else
            {
                _messageBroker.RaiseMessage("You do not have the required ingredients:");
                foreach(ItemQuantity itemQuantity in recipe.Ingredients)
                {
                    _messageBroker
                        .RaiseMessage($"  {itemQuantity.QuantityItemDescription}");
                }
            }
        }

        private void OnPlayerKilled(object sender, System.EventArgs e)
        {
            _messageBroker.RaiseMessage("");
            _messageBroker.RaiseMessage("You have been killed.");

            CurrentLocation = CurrentWorld.LocationAt(0, -1);
            CurrentPlayer.CompletelyHeal();
        }

        private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
        {
            // Get another monster to fight
            CurrentMonster = MonsterFactory.GetMonsterFromLocation(CurrentLocation);
        }

        private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
        {
            _messageBroker.RaiseMessage($"You are now level {CurrentPlayer.Level}!");
        }
    }
}

Step 3: Modify WPFUI\MainWindow.xaml

Currently, controls like labels, buttons, and textboxes, are placed on the UI by putting them in a grid. We’re going to start moving controls to a Canvas.

A Canvas gives us more control over placement of items (using X and Y positions).

The code for the new Canvas is on lines 53-131. The Canvas covers the two rows and columns of the non-menu part of the Window. To make sure the Canvas appears over the current datagrid-based controls, we’re going to use ZIndex values.

Think of the UI as having layers. The lower the number, the lower the layer. So, controls in layer 2 will be “on top” (covering) controls on layer 1.

On line 58, set the Canvas’ ZIndex to 99, to make sure it’s on top of all our other controls.

Set the ZIndex to 1 on the existing Grids we use for layout (lines 134-135, 194-196, 297-299, and 357-359).

Starting on line 61, we have a smaller Canvas that we bind to the InventoryDetails property. These properties set the Canvas’ position, min and max heights and widths, and its visibility, based on the property’s values.

Other than that, we wrap the previous Inventory’s datagrid with a border (line 70) and add an “X” button to close (hide) the Canvas on lines 89-99.

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:SOSCSRPG.ViewModels;assembly=SOSCSRPG.ViewModels"
        d:DataContext="{d:DesignInstance viewModels:GameSession}"
        mc:Ignorable="d"
        FontSize="11pt"
        Title="{Binding GameDetails.Title}" Height="768" Width="1024"
        KeyDown="MainWindow_OnKeyDown"
        Closing="MainWindow_OnClosing">

    <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 -->
        <Menu Grid.Row="0" Grid.Column="0"
              Grid.ColumnSpan="2"
              FontSize="11pt"
              Background="AliceBlue">
            <MenuItem Header="File">
                <MenuItem Header="New Game"
                          Click="StartNewGame_OnClick"/>
                <MenuItem Header="Save Game"
                          Click="SaveGame_OnClick"/>
                <Separator/>
                <MenuItem Header="Exit"
                          Click="Exit_OnClick"/>
            </MenuItem>
            <MenuItem Header="Help">
                <MenuItem Header="Help"
                          IsEnabled="False"/>
                <Separator/>
                <MenuItem Header="About"
                          IsEnabled="False"/>
            </MenuItem>
        </Menu>

        <!-- Main game canvas (full window) -->
        <Canvas Grid.Row="1" Grid.Column="0"
                Grid.RowSpan="2"
                Grid.ColumnSpan="2"
                x:Name="GameCanvas"
                ZIndex="99">

            <!-- Player Inventory Details -->
            <Canvas Top="{Binding InventoryDetails.Top}" Left="{Binding InventoryDetails.Left}"
                    Width="Auto" Height="Auto"
                    x:Name="PlayerInventoryDetailsPopup"
                    MinHeight="{Binding InventoryDetails.MinHeight}"
                    MaxHeight="{Binding InventoryDetails.MaxHeight}"
                    MinWidth="{Binding InventoryDetails.MinWidth}"
                    MaxWidth="{Binding InventoryDetails.MaxWidth}"
                    Visibility="{Binding InventoryDetails.IsVisible, Converter={StaticResource BooleanToVisibility}}">

                <Border BorderBrush="Navy" BorderThickness="3"
                        Background="LightSteelBlue">

                    <Grid Margin="2,2,2,2">
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="4"/>
                            <RowDefinition Height="*"/>
                        </Grid.RowDefinitions>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*"/>
                            <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>

                        <Label Grid.Row="0" Grid.Column="0"
                               HorizontalAlignment="Left"
                               FontWeight="Bold"
                               Content="Inventory"/>

                        <Button Grid.Row="0" Grid.Column="1"
                                Width="25"
                                FontWeight="Bold"
                                Content="X"
                                Click="CloseInventoryWindow_OnClick">
                            <Button.Resources>
                                <Style TargetType="Border">
                                    <Setter Property="CornerRadius" Value="3"/>
                                </Style>
                            </Button.Resources>
                        </Button>

                        <DataGrid Grid.Row="2" Grid.Column="0"
                                  Grid.ColumnSpan="2"
                                  ItemsSource="{Binding CurrentPlayer.Inventory.GroupedInventory}"
                                  AutoGenerateColumns="False"
                                  HeadersVisibility="Column"
                                  VerticalScrollBarVisibility="Auto"
                                  MaxHeight="{Binding RelativeSource={RelativeSource FindAncestor,
                                          AncestorType={x:Type Canvas}},Path=MaxHeight}"
                                  Width="{Binding RelativeSource={RelativeSource FindAncestor,
                                          AncestorType={x:Type Canvas}},Path=ActualWidth}">
                            <DataGrid.Columns>
                                <DataGridTextColumn Header="Description"
                                                    Binding="{Binding Item.Name, Mode=OneWay}"
                                                    Width="*"/>
                                <DataGridTextColumn Header="Qty"
                                                    IsReadOnly="True"
                                                    Width="Auto"
                                                    Binding="{Binding Quantity, Mode=OneWay}"/>
                                <DataGridTextColumn Header="Price"
                                                    Binding="{Binding Item.Price, Mode=OneWay}"
                                                    Width="Auto"/>
                            </DataGrid.Columns>
                        </DataGrid>

                    </Grid>

                </Border>

            </Canvas>

        </Canvas>

        <!-- Player stats -->
        <Grid Grid.Row="1" Grid.Column="0" Background="Aquamarine"
              ZIndex="1">
            <Grid.RowDefinitions>
                <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="Hit points:"/>
            <Label Grid.Row="1" Grid.Column="1" Content="{Binding CurrentPlayer.CurrentHitPoints}"/>
            <Label Grid.Row="2" Grid.Column="0" Content="Gold:"/>
            <Label Grid.Row="2" Grid.Column="1" Content="{Binding CurrentPlayer.Gold}"/>
            <Label Grid.Row="3" Grid.Column="0" Content="XP:"/>
            <Label Grid.Row="3" Grid.Column="1" Content="{Binding CurrentPlayer.ExperiencePoints}"/>
            <Label Grid.Row="4" Grid.Column="0" Content="Level:"/>
            <Label Grid.Row="4" Grid.Column="1" Content="{Binding CurrentPlayer.Level}"/>

            <!-- Player Attributes -->
            <ListBox Grid.Row="5" Grid.Column="0"
                      Grid.ColumnSpan="2"
                      Background="Aquamarine"
                      BorderThickness="0"
                      Grid.IsSharedSizeScope="True"
                      ItemsSource="{Binding CurrentPlayer.Attributes}">
                <ListBox.ItemTemplate>
                    <DataTemplate>
                        <StackPanel Orientation="Horizontal">
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition SharedSizeGroup="Description"/>
                                </Grid.ColumnDefinitions>
                                <TextBlock Text="{Binding DisplayName}" 
                                           HorizontalAlignment="Left"
                                           MinWidth="100"/>
                            </Grid>
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition SharedSizeGroup="ModifiedValue"/>
                                </Grid.ColumnDefinitions>
                                <TextBlock Text="{Binding ModifiedValue}"
                                           HorizontalAlignment="Right"/>
                            </Grid>
                        </StackPanel>
                    </DataTemplate>
                </ListBox.ItemTemplate>
            </ListBox>

        </Grid>

        <!-- Gameplay -->
        <Grid Grid.Row="1" Grid.Column="1"
              Background="Beige"
              ZIndex="1">
            <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, 
                                            Converter={StaticResource FileToBitmapConverter}}"/>

                    <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, 
                                            Converter={StaticResource FileToBitmapConverter}}"/>

                    <StackPanel Grid.Row="2"
                                Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
                                HorizontalAlignment="Center"
                                Orientation="Horizontal">
                        <TextBlock>Current Hit Points:</TextBlock>
                        <TextBlock Text="{Binding CurrentMonster.CurrentHitPoints}" />
                    </StackPanel>

                </Grid>

            </Border>

        </Grid>

        <!-- Inventory, Quests, and Recipes -->
        <Grid Grid.Row="2" Grid.Column="0"
              Background="BurlyWood"
              ZIndex="1">

            <TabControl x:Name="PlayerDataTabControl">
                <TabItem Header="Quests"
                         x:Name="QuestsTabItem">
                    <DataGrid ItemsSource="{Binding CurrentPlayer.Quests}"
                              AutoGenerateColumns="False"
                              HeadersVisibility="Column">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Name"
                                                Binding="{Binding PlayerQuest.Name, Mode=OneWay}"
                                                Width="*">
                                <DataGridTextColumn.CellStyle>
                                    <Style TargetType="DataGridCell">
                                        <Setter Property="ToolTip" 
                                                Value="{Binding PlayerQuest.ToolTipContents}"/>
                                    </Style>
                                </DataGridTextColumn.CellStyle>
                            </DataGridTextColumn>
                            <DataGridTextColumn Header="Done?"
                                                Binding="{Binding IsCompleted, Mode=OneWay}"
                                                Width="Auto"/>
                        </DataGrid.Columns>
                    </DataGrid>
                </TabItem>

                <TabItem Header="Recipes"
                         x:Name="RecipesTabItem">
                    <DataGrid ItemsSource="{Binding CurrentPlayer.Recipes}"
                              AutoGenerateColumns="False"
                              HeadersVisibility="Column">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Name"
                                                Binding="{Binding Name, Mode=OneWay}"
                                                Width="*">
                                <DataGridTextColumn.CellStyle>
                                    <Style TargetType="DataGridCell">
                                        <Setter Property="ToolTip" 
                                                Value="{Binding ToolTipContents}"/>
                                    </Style>
                                </DataGridTextColumn.CellStyle>
                            </DataGridTextColumn>
                            <DataGridTemplateColumn MinWidth="75">
                                <DataGridTemplateColumn.CellTemplate>
                                    <DataTemplate>
                                        <Button Click="OnClick_Craft"
                                                Width="55"
                                                Content="Craft"/>
                                    </DataTemplate>
                                </DataGridTemplateColumn.CellTemplate>
                            </DataGridTemplateColumn>
                        </DataGrid.Columns>
                    </DataGrid>
                </TabItem>
            </TabControl>
        </Grid>

        <!-- Action controls -->
        <Grid Grid.Row="2" Grid.Column="1"
              Background="Lavender"
              ZIndex="1">

            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="255" />
            </Grid.ColumnDefinitions>

            <!-- Combat Controls -->
            <Grid Grid.Row="0" Grid.Column="0"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>

                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="150"/>
                    <ColumnDefinition Width="10"/>
                    <ColumnDefinition Width="50"/>
                </Grid.ColumnDefinitions>

                <ComboBox Grid.Row="0" Grid.Column="0"
                          Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
                          ItemsSource="{Binding CurrentPlayer.Inventory.Weapons}"
                          SelectedItem="{Binding CurrentPlayer.CurrentWeapon}"
                          DisplayMemberPath="Name"/>

                <Button Grid.Row="0" Grid.Column="2"
                        Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
                        Content="Use"
                        Click="OnClick_AttackMonster"/>

                <ComboBox Grid.Row="1" Grid.Column="0"
                          Visibility="{Binding CurrentPlayer.Inventory.HasConsumable, Converter={StaticResource BooleanToVisibility}}"
                          ItemsSource="{Binding CurrentPlayer.Inventory.Consumables}"
                          SelectedItem="{Binding CurrentPlayer.CurrentConsumable}"
                          DisplayMemberPath="Name"/>

                <Button Grid.Row="1" Grid.Column="2"
                        Visibility="{Binding CurrentPlayer.Inventory.HasConsumable, Converter={StaticResource BooleanToVisibility}}"
                        Content="Use"
                        Click="OnClick_UseCurrentConsumable"/>
            </Grid>

            <!-- 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="1" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_DisplayTradeScreen"
                        Visibility="{Binding HasTrader, Converter={StaticResource BooleanToVisibility}}"
                        Content="Trade"/>
                <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 4: Modify MainWindow.xaml.cs

To let the player move the Canvas on the screen, we need to monitor some mouse events: MouseDown, MouseMove, and MouseUp.

On line 26, add the private class-level variable _dragStart. This will let use keep track of the relative position of where the player clicked on Inventory Canvas, when they drag it to a new position.

In the constructor, starting on line 36, connect the mouse eventhandlers to the canvases that are on the main game Canvas. Right now, we only have one child Canvas – the Inventory details one. In the future, we might create a custom UI control for these moveable canvases.

In the InitializeUserInputActions function, on line 109, add a new line to connect the key press for the letter “I” to switch the IsVisible property on the InventoryDetails property. This will handle the hide/show for the InventoryDetails Canvas.

While we’re in here, add the “e.Handled = true;” line to the MainWindow_OnKeyDown function (line 121).

By setting e.Handled to true, we tell the rest of the program that the keypress has been handled. There’s nothing more that the rest of the program needs to do with the keypress.

On lines 206-257, add the new functions for the InventoryDetails Canvas.

CloseInventoryWindow_OnClick will hide the Canvas when the user clicks the “X” button we added on the Canvas.

In the GameCanvas_OnMouseDown function, we check that the mouse button clicked is the left button. If it is, we get the element that was clicked on, set the _dragpoint variable to where the mouse was clicked (relative to the InventoryDetails’ Canvas), and Call CaptureMouse on the Canvas – which will tie future mouse events to that Canvas.

The GameCanvas_OnMouseUp function (on lines 250-257) does the opposite – releasing the mouse capture and clearing out the _dragStart variable.

GameCanvas_OnMouseMove (lines 225-248) repositions the InventoryDetails Canvas, based on the mouse’s current location. I added the code on lines 236-242 to check that the mouse wasn’t moved past the borders of the game screen. This logic isn’t perfect – I think the mouse moves faster than the events fire, so we may need to make changes here in the future.

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 SOSCSRPG.Models;
using SOSCSRPG.Services;
using SOSCSRPG.ViewModels;
using Microsoft.Win32;
using SOSCSRPG.Core;
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;
        private Point? _dragStart;

        public MainWindow(Player player, int xLocation = 0, int yLocation = 0)
        {
            InitializeComponent();

            InitializeUserInputActions();

            SetActiveGameSessionTo(new GameSession(player, xLocation, yLocation));

            // Enable drag for popup details canvases
            foreach (UIElement element in GameCanvas.Children)
            {
                if (element is Canvas)
                {
                    element.MouseDown += GameCanvas_OnMouseDown;
                    element.MouseMove += GameCanvas_OnMouseMove;
                    element.MouseUp += GameCanvas_OnMouseUp;
                }
            }
        }

        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, () => _gameSession.InventoryDetails.IsVisible = !_gameSession.InventoryDetails.IsVisible);
            _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();

                e.Handled = true;
            }
        }

        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)
        {
            Startup startup = new Startup();
            startup.Show();
            Close();
        }

        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)
        {
            AskToSaveGame();
        }

        private void AskToSaveGame()
        {
            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(new GameState(_gameSession.CurrentPlayer, 
                    _gameSession.CurrentLocation.XCoordinate, 
                    _gameSession.CurrentLocation.YCoordinate), saveFileDialog.FileName);
            }
        }

        private void CloseInventoryWindow_OnClick(object sender, RoutedEventArgs e)
        {
            _gameSession.InventoryDetails.IsVisible = false;
        }

        private void GameCanvas_OnMouseDown(object sender, MouseButtonEventArgs e)
        {
            if (e.ChangedButton != MouseButton.Left)
            {
                return;
            }

            UIElement movingElement = (UIElement)sender;
            _dragStart = e.GetPosition(movingElement);
            movingElement.CaptureMouse();

            e.Handled = true;
        }

        private void GameCanvas_OnMouseMove(object sender, MouseEventArgs e)
        {
            if (_dragStart == null || e.LeftButton != MouseButtonState.Pressed)
            {
                return;
            }

            Point mousePosition = e.GetPosition(GameCanvas);
            UIElement movingElement = (UIElement)sender;

            // Don't let player move popup details off the board
            if (mousePosition.X < _dragStart.Value.X ||
                mousePosition.Y < _dragStart.Value.Y ||
                mousePosition.X > GameCanvas.ActualWidth - ((Canvas)movingElement).ActualWidth + _dragStart.Value.X ||
                mousePosition.Y > GameCanvas.ActualHeight - ((Canvas)movingElement).ActualHeight + _dragStart.Value.Y)
            {
                return;
            }

            Canvas.SetLeft(movingElement, mousePosition.X - _dragStart.Value.X);
            Canvas.SetTop(movingElement, mousePosition.Y - _dragStart.Value.Y);

            e.Handled = true;
        }

        private void GameCanvas_OnMouseUp(object sender, MouseButtonEventArgs e)
        {
            var movingElement = (UIElement)sender;
            movingElement.ReleaseMouseCapture();
            _dragStart = null;

            e.Handled = true;
        }
    }
}

Step 5: Test the game

Leave a Reply

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