Lesson 4.8: Create Centralized Message Broker

As we plan to refactor and expand the combat system, we notice that there are a lot of messages returned from combat methods that need to be displayed. Our plan is to refactor the combat code out of the GameSession class and into its own class that manages the intricacies of combat.

To make it easier to move the combat logic into its own class, we need to create a separate DisplayMessageBroker class that can be used by any object in the game to send messages and is watched by the presentation layer. The DisplayMessageBroker is an example of the Observer design pattern.

The Observer pattern is a form of the publisher/subscriber model that uses events to sent messages between two loosely coupled objects… in our case it’s the combat class and the displayed messages list. The pattern defines Subject and Observer objects. When a subject changes state, all registered observers are notified and updated automatically.

Fig 1 – Observer Pattern Class Diagram
  • The sole responsibility of a subject is to maintain a list of observers and to notify them of state changes by using an event. The DisplayMessageBroker is our subject.
  • The responsibility of observers is to register (and unregister) themselves on a subject (to get notified of state changes) and to update their state when they are notified.
  • This makes subjects and observers loosely coupled. Subjects and observers have no explicit knowledge of each other. Observers can be added and removed independently at run-time.
  • This notification-registration interaction is also known as publish-subscribe.

For now, we’ll only have one subscriber/observer, the GameScreen. But, this message broker will give us flexibility and make it easier to move our current combat code into another class.

Game Engine Changes

To start these changes, we are going to create the DisplayMessageBroker class in the SimpleRPG.Game.Engine project and Services folder.

using SimpleRPG.Game.Engine.Models;
using System;

namespace SimpleRPG.Game.Engine.Services
{
    public class DisplayMessageBroker
    {
        // Use the Singleton design pattern for this class,
        // to ensure everything in the game sends messages through this one object.
        private static readonly DisplayMessageBroker _messageBroker = new DisplayMessageBroker();

        private DisplayMessageBroker()
        {
        }

        public event EventHandler<DisplayMessage>? OnMessageRaised;

        public static DisplayMessageBroker Instance => _messageBroker;

        public void RaiseMessage(DisplayMessage message)
        {
            if (OnMessageRaised != null)
            {
                OnMessageRaised.Invoke(this, message);
            }
        }
    }
}

The DisplayMessageBroker also follows the Singleton pattern (which we learned about in Lesson 3.4) because we want every object in the game engine to use one shared instance of the broker. To implement the Singleton pattern (lines #10-14 and 18), we will:

  • Make the constructor private, so it cannot be instantiated by any other class.
  • Create a private static variable that holds the only instance of the DisplayMessageBroker.
  • Create a static Instance read-only property that returns the single instance of this class.

Then, we define a public event called OnMessageRaised, which provides the place where subscribers can register their event handlers to respond to changes raised by our broker.

Finally, the RaiseMessage method invokes any registered event handlers when it is called. Anywhere in the game engine, we can call DisplayMessageBroker.Instance.RaiseMessage and that will fire the event and notify all subscribers that a new DisplayMessage has been sent.

Now that we have our message broker, we need to call it show a DisplayMessage. To verify this lesson only, I will update the GameSession to use this broker to duplicate sending a game message.

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class GameSession : IGameSession
    {
        private readonly World _currentWorld;
        private readonly int _maximumMessagesCount = 100;
        private readonly Dictionary<string, Action> _userInputActions = new Dictionary<string, Action>();

        public Player CurrentPlayer { get; private set; }

        public Location CurrentLocation { get; private set; }

        public Monster? CurrentMonster { get; private set; }

        public bool HasMonster => CurrentMonster != null;

        public Trader? CurrentTrader { get; private set; }

        public MovementUnit Movement { get; private set; }

        public IList<DisplayMessage> Messages { get; } = new List<DisplayMessage>();

        public GameSession(int maxMessageCount)
            : this()
        {
            _maximumMessagesCount = maxMessageCount;
        }

        public GameSession()
        {
            InitializeUserInputActions();

            CurrentPlayer = new Player
            {
                Name = "DarthPedro",
                CharacterClass = "Fighter",
                CurrentHitPoints = 10,
                MaximumHitPoints = 10,
                Gold = 1000,
                Level = 1
            };

            _currentWorld = WorldFactory.CreateWorld();

            Movement = new MovementUnit(_currentWorld);
            CurrentLocation = Movement.CurrentLocation;
            GetMonsterAtCurrentLocation();

            if (!CurrentPlayer.Inventory.Weapons.Any())
            {
                CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
            }

            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(2001));
            CurrentPlayer.LearnRecipe(RecipeFactory.GetRecipeById(1));
            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3001));
            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3002));
            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3003));
        }

        public void OnLocationChanged(Location newLocation)
        {
            _ = newLocation ?? throw new ArgumentNullException(nameof(newLocation));

            CurrentLocation = newLocation;
            Movement.UpdateLocation(CurrentLocation);
            GetMonsterAtCurrentLocation();
            CompleteQuestsAtLocation();
            GetQuestsAtLocation();
            CurrentTrader = CurrentLocation.TraderHere;
        }

        public void AttackCurrentMonster(GameItem? currentWeapon)
        {
            if (CurrentMonster is null)
            {
                return;
            }

            if (currentWeapon is null)
            {
                AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
                return;
            }

            // player acts monster with weapon
            CurrentPlayer.CurrentWeapon = currentWeapon;
            var message = CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
            AddDisplayMessage(message);

            // if monster is killed, collect rewards and loot
            if (CurrentMonster.IsDead)
            {
                OnCurrentMonsterKilled(CurrentMonster);

                // get another monster to fight
                GetMonsterAtCurrentLocation();
            }
            else
            {
                // if monster is still alive, let the monster attack
                message = CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
                AddDisplayMessage(message);

                // if player is killed, move them back to their home and heal.
                if (CurrentPlayer.IsDead)
                {
                    OnCurrentPlayerKilled(CurrentMonster);
                }
            }
        }

        public void ConsumeCurrentItem(GameItem? item)
        {
            if (item is null || item.Category != GameItem.ItemCategory.Consumable)
            {
                AddDisplayMessage("Item Warning", "You must select a consumable item to use.");
                return;
            }

            // player uses consumable item to heal themselves and item is removed from inventory.
            CurrentPlayer.CurrentConsumable = item;
            var message = CurrentPlayer.UseCurrentConsumable(CurrentPlayer);
            AddDisplayMessage(message);
        }

        public void CraftItemUsing(Recipe recipe)
        {
            _ = recipe ?? throw new ArgumentNullException(nameof(recipe));

            var lines = new List<string>();

            if (CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
            {
                CurrentPlayer.Inventory.RemoveItems(recipe.Ingredients);

                foreach (ItemQuantity itemQuantity in recipe.OutputItems)
                {
                    for (int i = 0; i < itemQuantity.Quantity; i++)
                    {
                        GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);
                        CurrentPlayer.Inventory.AddItem(outputItem);
                        lines.Add($"You craft 1 {outputItem.Name}");
                    }
                }

                AddDisplayMessage("Item Creation", lines);
            }
            else
            {
                lines.Add("You do not have the required ingredients:");
                foreach (ItemQuantity itemQuantity in recipe.Ingredients)
                {
                    lines.Add($"  {itemQuantity.Quantity} {ItemFactory.GetItemName(itemQuantity.ItemId)}");
                }

                AddDisplayMessage("Item Creation", lines);
            }
        }

        public void ProcessKeyPress(KeyProcessingEventArgs args)
        {
            _ = args ?? throw new ArgumentNullException(nameof(args));

            var key = args.Key.ToUpper();
            if (_userInputActions.ContainsKey(key))
            {
                _userInputActions[key].Invoke();
            }
        }

        private void OnCurrentPlayerKilled(Monster currentMonster)
        {
            AddDisplayMessage("Player Defeated", $"The {currentMonster.Name} killed you.");

            CurrentPlayer.CompletelyHeal();  // Completely heal the player
            this.OnLocationChanged(_currentWorld.LocationAt(0, -1));  // Return to Player's home
        }

        private void OnCurrentMonsterKilled(Monster currentMonster)
        {
            var messageLines = new List<string>();
            messageLines.Add($"You defeated the {currentMonster.Name}!");

            CurrentPlayer.AddExperience(currentMonster.RewardExperiencePoints);
            messageLines.Add($"You receive {currentMonster.RewardExperiencePoints} experience points.");

            CurrentPlayer.ReceiveGold(currentMonster.Gold);
            messageLines.Add($"You receive {currentMonster.Gold} gold.");

            foreach (GameItem item in currentMonster.Inventory.Items)
            {
                CurrentPlayer.Inventory.AddItem(item);
                messageLines.Add($"You received {item.Name}.");
            }

            AddDisplayMessage("Monster Defeated", messageLines);
        }

        private void GetMonsterAtCurrentLocation()
        {
            CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;

            if (CurrentMonster != null)
            {
                AddDisplayMessage("Monster Encountered:", $"You see a {CurrentMonster.Name} here!");
                DisplayMessageBroker.Instance.RaiseMessage(new DisplayMessage("Monster Encountered2:", $"You see a {CurrentMonster.Name} here!"));
            }
        }

        private void GetQuestsAtLocation()
        {
            foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                if (!CurrentPlayer.Quests.Any(q => q.PlayerQuest.Id == quest.Id))
                {
                    CurrentPlayer.Quests.Add(new QuestStatus(quest));
                    AddDisplayMessage(quest.ToDisplayMessage());
                }
            }
        }

        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))
                    {
                        // Remove the quest completion items from the player's inventory
                        CurrentPlayer.Inventory.RemoveItems(quest.ItemsToComplete);

                        // give the player the quest rewards
                        var messageLines = new List<string>();
                        CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
                        messageLines.Add($"You receive {quest.RewardExperiencePoints} experience points");

                        CurrentPlayer.ReceiveGold(quest.RewardGold);
                        messageLines.Add($"You receive {quest.RewardGold} gold");

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

                            CurrentPlayer.Inventory.AddItem(rewardItem);
                            messageLines.Add($"You receive a {rewardItem.Name}");
                        }

                        AddDisplayMessage($"Quest Completed - {quest.Name}", messageLines);

                        // mark the quest as completed
                        questToComplete.IsCompleted = true;
                    }
                }
            }
        }

        private void InitializeUserInputActions()
        {
            _userInputActions.Add("W", () => Movement.MoveNorth());
            _userInputActions.Add("A", () => Movement.MoveWest());
            _userInputActions.Add("S", () => Movement.MoveSouth());
            _userInputActions.Add("D", () => Movement.MoveEast());
            _userInputActions.Add("ARROWUP", () => Movement.MoveNorth());
            _userInputActions.Add("ARROWLEFT", () => Movement.MoveWest());
            _userInputActions.Add("ARROWDOWN", () => Movement.MoveSouth());
            _userInputActions.Add("ARROWRIGHT", () => Movement.MoveEast());
        }

        private void AddDisplayMessage(string title, string message) =>
            AddDisplayMessage(title, new List<string> { message });

        private void AddDisplayMessage(string title, IList<string> messages)
        {
            AddDisplayMessage(new DisplayMessage(title, messages));
        }

        public void AddDisplayMessage(DisplayMessage message)
        {
            this.Messages.Insert(0, message);

            if (Messages.Count > _maximumMessagesCount)
            {
                Messages.Remove(Messages.Last());
            }
        }
    }
}

In line #214, we grab the single instance of the DisplayMessageBroker and call the RaiseMessage method to show a duplicate message when we find a monster at the current location.

Making Page the Observer

Now that our view model raises a new DisplayMessage event, we need to define our first observer/subscriber. We will do that in the GameScreen page.

@page "/"
@inject IJSRuntime jsRuntime
@inject IGameSession ViewModel

<div @onkeydown="@KeyDown" tabindex="0" @ref="pageRoot">
    <Row Margin="Margin.Is0" Style="height: 5vh; min-height: 32px">
        <Column ColumnSize="ColumnSize.Is12" Style="background-color: lightgrey">
            <Heading Size="HeadingSize.Is3">Simple RPG</Heading>
        </Column>
    </Row>
    <Row Margin="Margin.Is0" Style="height: 60vh">
        <Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Style="background-color: aquamarine">
            <PlayerComponent Player="@ViewModel.CurrentPlayer" />
        </Column>
        <Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12" Style="background-color: beige">
            <Row Margin="Margin.Is2.OnY">
                <Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
                    <DisplayMessageListView Messages="@ViewModel.Messages" />
                </Column>
                <Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
                    <LocationComponent Location="@ViewModel.CurrentLocation" />
                    <MonsterComponent Monster="@ViewModel.CurrentMonster" />
                    <TraderComponent Trader="@ViewModel.CurrentTrader" Player="@ViewModel.CurrentPlayer"
                                     InventoryChanged="@StateHasChanged" />
                </Column>
            </Row>
        </Column>
    </Row>
    <Row Margin="Margin.Is0" Style="height: 33vh">
        <Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Padding="Padding.Is2.OnY"
                Style="background-color: burlywood">
            <PlayerTabs Player="@ViewModel.CurrentPlayer" CraftItemClicked="@ViewModel.CraftItemUsing"
                        DisplayMessageCreated="@ViewModel.AddDisplayMessage" />
        </Column>
        <Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12" Style="background-color: lavender">
            <Row Margin="Margin.Is2.OnY">
                <Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
                    <CombatComponent WeaponList="@ViewModel.CurrentPlayer.Inventory.Weapons"
                                     AttackClicked="@ViewModel.AttackCurrentMonster"
                                     LocationHasMonster="@ViewModel.HasMonster"
                                     ConsumableList="@ViewModel.CurrentPlayer.Inventory.Consumables"
                                     ConsumeClicked="@ViewModel.ConsumeCurrentItem" />
                </Column>
                <Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
                    <MovementComponent Movement="@ViewModel.Movement"
                                       LocationChanged="@ViewModel.OnLocationChanged" />
                </Column>
            </Row>
        </Column>
    </Row>
</div>

@code {
    protected ElementReference pageRoot;  // set the @ref for attribute

    protected async override Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await jsRuntime.InvokeVoidAsync("SetFocusToElement", pageRoot);
        }
    }

    protected override void OnInitialized()
    {
        DisplayMessageBroker.Instance.OnMessageRaised += OnGameMessageRaised;
    }

    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
    private void KeyDown(KeyboardEventArgs args) =>
        ViewModel.ProcessKeyPress(args.ToKeyProcessingEventArgs());

    private void OnGameMessageRaised(object sender, DisplayMessage message) =>
        ViewModel.AddDisplayMessage(message);
}

First, we override the page’s OnInitialized method (lines #64-67). This method is called during the component startup lifecycle. It is a good place to enter one-time initialization code. In this method, we register the page’s event handler with the DisplayMessageBroker.

Then, we define the OnGameMessageRaised method (lines 73-74). All the event handler does is forward the DisplayMessage to ViewModel.AddDisplayMessage to get the new message added to the GameSession.Messages list that gets displayed.

To get this code to compile, we need to also add the SimpleRPG.Game.Engine.Services namespace to the _Imports.razor file (line #13), so that it is accessible to all Razor components.

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop

@using SimpleRPG.Game
@using SimpleRPG.Game.Helpers
@using SimpleRPG.Game.Shared
@using SimpleRPG.Game.Engine.Models
@using SimpleRPG.Game.Engine.Services 
@using SimpleRPG.Game.Engine.ViewModels 

@using Blazorise 

Now we can build and run our game again. If we move to a location with a monster in it (like the Farmer’s Field), we will see duplicate encounter methods. This proves that our message broker is sending events correctly and our observer on the GameScreen page is receiving them.

Fig 2 – Using DisplayMessageBroker to show encounter message

As you may have noted, the code in this lesson fires an event in the GameSession view model, listens to it on the page, and then forwards that message back to the GameSession.AddDisplayMessage method. That is a round-about way to add a message in the GameSession… since we can also just directly call AddDisplayMessage.

We added the message broker call to GameSession in this lesson just to prove that our broker, subject, and observer were all working together correctly. In the next lesson, we will remove this code again and have our new battle class use the message broker instead. This lesson was just preparing us for the next step in our combat refactoring.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s