Lesson 4.6: Add Keyboard Shortcuts for Game Operations

Part of a good game is being able to take multiple forms of input. To this point, we have focused on touch and mouse input. In this lesson, we will add keyboard input to the game and handle keyboard keys and shortcuts to perform specific game actions, like moving or fighting or using a consumable item.

We will look at two forms of keyboard handling. First, we will directly process specific keys, like the arrow keys, to move the player around the game world. Second, we will use accelerator (or access) keys that are common HTML mechanism for defining shortcut keys for buttons.

ViewModel Key Processing

Since most of our operations are managed by our GameSession view model, we will add keyboard processing to this class as well. We will process specific keys and map them to our move operations. We use this form of key processing so that we can have a couple of different keysets for movement… the arrow keys will move the player in the corresponding direction: north, west, south, east. And most gamers are familiar with the WASD keys also providing movement (W – north, A – west, S – south, D – east).

Let’s look at the code changes to enable this behavior. First, we define the ProcessKeyPress method in the IGameSession interface.

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

namespace SimpleRPG.Game.Engine.ViewModels
{
    public interface IGameSession
    {
        Player CurrentPlayer { get; }

        Location CurrentLocation { get; }

        Monster? CurrentMonster { get; }

        bool HasMonster { get; }

        Trader? CurrentTrader { get; }

        MovementUnit Movement { get; }

        IList<DisplayMessage> Messages { get; }

        void OnLocationChanged(Location newLocation);

        void AttackCurrentMonster(GameItem? currentWeapon);

        void ConsumeCurrentItem(GameItem? item);

        void CraftItemUsing(Recipe recipe);

        void ProcessKeyPress(KeyProcessingEventArgs args);
    }
}

As we can see, we need a new class that holds the key event information (like which key was pressed and the state of special keys – like shift, alt, ctrl). While Blazor and ASP.NET does define the KeyboardEventArgs class for this, we do not want to include a reference to that type in our game engine library, because it would introduce a dependency on ASP.NET assemblies in our engine. To keep a clean separate in our design (including if we want to use the game engine to target different presentation systems), we will create our own type to hold key event information.

So in the ViewModels folder, let’s create the KeyProcessingEventArgs class:

using System;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class KeyProcessingEventArgs : EventArgs
    {
        public string Key { get; set; } = string.Empty;

        public string Code { get; set; } = string.Empty;

        public float Location { get; set; }

        public bool Repeat { get; set; }

        public bool CtrlKey { get; set; }

        public bool ShiftKey { get; set; }

        public bool AltKey { get; set; }

        public bool MetaKey { get; set; }
    }
}

This class matches the KeyboardEventArgs class in structure.

Now we need to implement the changes in our GameSession class with the following updates:

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 IDiceService _diceService;
        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(IDiceService? diceService = null)
        {
            _diceService = diceService ?? DiceService.Instance;
            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!");
            }
        }

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

                    var messageLines = new List<string>
                    {
                        quest.Description,
                        "Items to complete the quest:"
                    };

                    foreach (ItemQuantity q in quest.ItemsToComplete)
                    {
                        messageLines.Add($"    {ItemFactory.CreateGameItem(q.ItemId).Name} (x{q.Quantity})");
                    }

                    messageLines.Add("Rewards for quest completion:");
                    messageLines.Add($"   {quest.RewardExperiencePoints} experience points");
                    messageLines.Add($"   {quest.RewardGold} gold");
                    foreach (ItemQuantity itemQuantity in quest.RewardItems)
                    {
                        messageLines.Add($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemId).Name} (x{itemQuantity.Quantity})");
                    }

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

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

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

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

There are a few changes required to process the key presses in this view model:

  1. We defined a new Dictionary (line #15) that uses the keypress text value as the dictionary key, and also takes an action to perform. Action is a .NET class that represents a delegate method that gets called when it is invoked. So this dictionary contains a set of name-value pairs for keypress text and Action.
  2. We call the InitializeUserInputActions method in the GameSession constructor (line #40) so that all of the keyboard mappings get defined at the start.
  3. Then, we implement the InitializeUserInputActions method (lines #291-301), which creates a new dictionary entry for every key mapping that we want to support. We will create two mappings for movement keys – arrow keys and WASD keys. As we can see, each Action corresponds to moves in the appropriate direction.
  4. Finally, we implement the ProcessKeyPress method (lines #170-179). This method gets called by the presentation layer whenever a key is pressed. We change all of the key presses to upper case text, so that it is easier to compare. Then we look for the key in our _userInputActions mapping dictionary. If we find the key, then we call Invoke on the corresponding Action.

With this code complete, our view model can process the movement keys that we defined.

Blazor Key Processing

In order to get onkeypress, onkeydown, and onkeyup events in Blazor (and ASP.NET in general), we need to ensure that a particular <div> element has focus. Focus is usually show as a visible border around a div, and it is where all keyboard input is sent. To be able to process keyboard input in our game, we need to do these two things. To see more details about getting keyboard events in Blazor, check out this article.

In order to set focus to a <div>, we need to write a snippet of JavaScript to set focus to a Blazor (or HTML) element. This is the first JavaScript we have had to include in our project (thanks mainly to the Blazorise library). So we will go to the SimpleRPG.Game project and wwwroot folder and create the game-scripts.js file. This is a new JavaScript file where we will put any scripts that we may need.

window.SetFocusToElement = (element) => {
    element.focus();
};

This is a straightforward function that calls the element.focus() method with the element passed into the function. This will set input focus to any element that we specify.

Then, we need to include this JavaScript file into our main index.html page, so that it is discovered and loaded onto the page.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>SimpleRPG.Game</title>
    <base href="/" />

    <!-- Begin: Blazorise required css files -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
    <link href="_content/Blazorise/blazorise.css" rel="stylesheet" />
    <link href="_content/Blazorise.Bootstrap/blazorise.bootstrap.css" rel="stylesheet" />
    <!-- End: Blazorise required css files -->

    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
</head>

<body>
    <app>
        <div style="margin: 0 auto; width: 100vw; height: 100vh; background-color: #004A00">
            <div style="text-align:center; color: white; font-size: 16pt; margin-top: 12px">Loading...</div>
            <div style="text-align:center; color: white; margin-top: 12px"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="font-size: 16pt; width: 36px; height: 36px;"></span></div>
            <div style="height:660px; line-height:660px; text-align:center"><img src="/images/SplashScreenLogo.png" style="vertical-align:middle" /></div>
        </div>
    </app>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
    <script>navigator.serviceWorker.register('service-worker.js');</script>
    <script src="game-scripts.js"></script>

    <!-- Begin: Blazorise required script files -->
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>

    <script src="_content/Blazorise/blazorise.js"></script>
    <script src="_content/Blazorise.Bootstrap/blazorise.bootstrap.js"></script>
    <!-- End: Blazorise required script files -->
</body>

</html>

With this in place, we need a couple of helper changes to simplify our integration. Let’s start by creating the KeyboardEventArgsHelper class in the SimpleRPG.Game project and Helpers folder.

using Microsoft.AspNetCore.Components.Web;
using SimpleRPG.Game.Engine.ViewModels;
using System;

namespace SimpleRPG.Game.Helpers
{
    public static class KeyboardEventArgsHelper
    {
        public static KeyProcessingEventArgs ToKeyProcessingEventArgs(this KeyboardEventArgs args)
        {
            _ = args ?? throw new ArgumentNullException(nameof(args));

            return new KeyProcessingEventArgs
            {
                AltKey = args.AltKey,
                Code = args.Code,
                CtrlKey = args.CtrlKey,
                Key = args.Key,
                Location = args.Location,
                MetaKey = args.MetaKey,
                Repeat = args.Repeat,
                ShiftKey = args.ShiftKey
            };
        }
    }
}

This method is solely responsible for converting a KeyboardEventArgs object (defined in the ASP.NET assemblies) into an KeyProcessingEventArgs instance (defined in our game engine project) so that we can pass keyboard events to our GameSession view model. The method just simply creates the type, sets the properties from the args passed into it, and then returns the result. This method is also an extension method (we discussed these in earlier lessons in Chapter 2), so it can be called as if it were a new method of the KeyboardEventArgs class.

Then, we need to add the SimpleRPG.Game.Helpers namespace to our _Imports.razor file, so that these helper classes can be easily referenced in our 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.ViewModels 

@using Blazorise 

Finally, we have a few changes to make to the MainScreen.razor file to enable the keyboard processing and make use of the script and class above.

@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" />
        </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 void KeyDown(KeyboardEventArgs args) =>
        ViewModel.ProcessKeyPress(args.ToKeyProcessingEventArgs());
}

Let’s review the changes to this file:

  1. We inject a new service for IJSRuntime (line #2) into the page. This service lets us interact with the JavaScript engine and scripts on the main page.
  2. Then we add a new <div> element around our whole page (line #5 and 50). We do this so that focus is set to the entire page and not just a subsection. This element must have a tabindex set, an event handler for onkeydown, and an ElementReference that we can use in our code and JavaScript call. We use the ElementReference so that we don’t have to find HTML elements by name or other search methods.
  3. In our @code section, we define the ElementReference for the pageRoot div (line #53). From this point on, our code can use the member when we wish to perform an action on that associated div element in the HTML definition.
  4. Then, we override the OnAfterRenderAsync method (lines #55-61). If this is the very first render on this page, we use the IJSRuntime service to invoke our Javascript function. The InvokeVoidAsync method finds the function by its name and passes the pageRoot element as its parameter. This ensures that the input focus is set to the root element on this page as it loads.
  5. Finally, we have the KeyDown event handler (lines #63-64), which just forwards the keyboard information to the ViewModel via the ProcessKeyPress method… Notice that we convert the EventArgs along the way.

After making all of these changes, we can run the game again and now when we use the arrow keys, we will move around our game world as though we were pressing the movement buttons on screen… and same for the WASD keys as well.

Keyboard Accelerators

Another way to forward keyboard input it to use the accesskey attribute on some HTML elements (like buttons). With these accelerators, we rely on the HTML page to manage the keyboard processing and mapping the appropriate keys to operations. To invoke an accelerator, the player must press the Alt key and the key defined in the accesskey attribute. Doing so simulates a click event on that element.

Since the logic for processing the accesskey is built into the HTML engine, all we have to do is define the access key on the appropriate elements. Let’s start by adding them to the CombatComponent.

<Heading Size="HeadingSize.Is5" Margin="Margin.Is2.OnY">Combat</Heading>
<Row Margin="Margin.Is2.OnY">
    <Column ColumnSize="ColumnSize.Is6.Is2.WithOffset" Margin="Margin.Is1.FromTop">
        <Select id="weapons-select" TValue="int" @bind-SelectedValue="SelectedWeaponId">
            @foreach (GameItem weapon in WeaponList)
            {
                <SelectItem TValue="int" Value="@weapon.ItemTypeID">@weapon.Name</SelectItem>
            }
        </Select>
    </Column>
    <Column ColumnSize="ColumnSize.Is2">
        <Button id="attack-monster-btn" Color="Color.Secondary" Margin="Margin.Is1"
                Disabled="@disableAttack" Clicked="OnAttackClicked" accesskey="k">
            Attack!
        </Button>
    </Column>
</Row>
<Row Margin="Margin.Is2.OnY">
    <Column ColumnSize="ColumnSize.Is6.Is2.WithOffset" Margin="Margin.Is1.FromTop">
        <Select id="consumables-select" TValue="int" @bind-SelectedValue="SelectedConsumableId">
            @foreach (GameItem item in ConsumableList)
            {
                <SelectItem TValue="int" Value="@item.ItemTypeID">@item.Name</SelectItem>
            }
        </Select>
    </Column>
    <Column ColumnSize="ColumnSize.Is2">
        <Button id="use-consumable-btn" Color="Color.Secondary" Margin="Margin.Is1"
                Disabled="@disableUse" Clicked="OnConsumeClicked" accesskey="u">
            Use!
        </Button>
    </Column>
</Row>

@code {
    private bool disableAttack => !WeaponList.Any() || LocationHasMonster == false;
    private bool disableUse => !ConsumableList.Any();

    private int SelectedWeaponId { get; set; }
    private int SelectedConsumableId { get; set; }

    [Parameter]
    public IEnumerable<GameItem> WeaponList { get; set; } = Array.Empty<GameItem>();

    [Parameter]
    public IEnumerable<GameItem> ConsumableList { get; set; } = Array.Empty<GameItem>();

    [Parameter]
    public bool LocationHasMonster { get; set; } = false;

    [Parameter]
    public EventCallback<GameItem?> AttackClicked { get; set; }

    [Parameter]
    public EventCallback<GameItem?> ConsumeClicked { get; set; }

    protected override void OnInitialized()
    {
        SelectedWeaponId = WeaponList.Any() ? WeaponList.First().ItemTypeID : 0;
        SelectedConsumableId = ConsumableList.Any() ? ConsumableList.First().ItemTypeID : 0;
    }

    public void OnAttackClicked()
    {
        var weapon = SelectedWeaponId > 0 ? WeaponList.First(f => f.ItemTypeID == SelectedWeaponId) : null;
        AttackClicked.InvokeAsync(weapon);
    }

    public void OnConsumeClicked()
    {
        var item = SelectedConsumableId > 0 ? ConsumableList.First(f => f.ItemTypeID == SelectedConsumableId) : null;
        ConsumeClicked.InvokeAsync(item);
    }
}

We define two accelerators in this component:

  1. Alt+K (line #13): defines accesskey="k". This simulates the Attack button being clicked.
  2. Alt+U (line #29): defines accesskey="u". This simulates the Use button being clicked.

The great part about using accelerators/access keys is that they only activate if the buttons are enabled. When the Attack and Use buttons are disabled, these accelerators don’t do anything. So there isn’t any need for us to build special detection logic for enabled/disabled operations. If we were to process these keys ourselves, we would have to decide when it’s appropriate to process the operation.

Finally, we will add one last accelerator to the TraderComponent to launch the TraderScreen when Alt+T is pressed.

@inject TraderViewModel ViewModel

@if (ViewModel.Trader != null)
{
<Row Margin="Margin.Is2.OnY">
    <Column Class="text-center">
        <Button id="show-trader-btn" Color="Color.Secondary" Margin="Margin.Is1"
                Clicked="@modal.ShowModal" accesskey="t">
            Trader
        </Button>
    </Column>
</Row>

<Modal @ref="@modal.ModalRef" id="trader-modal">
    <ModalBackdrop id="trader-modal-backdrop" Style="z-index: 0" />
    <ModalContent Centered="true" Size="ModalSize.Large">
        <ModalHeader>
            <ModalTitle>Trader - @ViewModel.Trader.Name</ModalTitle>
            <CloseButton id="header-close-btn" Clicked="@modal.HideModal" />
        </ModalHeader>
        <ModalBody>
            <Row>
                <Column ColumnSize="ColumnSize.Is6.OnWidescreen.Is12">
                    <div class="text-center">Your Inventory</div>
                    <Table Bordered="true" Narrow="true" Striped="true" Margin="Margin.Is2.OnY">
                        <TableHeader>
                            <TableHeaderCell>Name</TableHeaderCell>
                            <TableHeaderCell Class="text-center">Qty</TableHeaderCell>
                            <TableHeaderCell Class="text-center">Price</TableHeaderCell>
                            <TableHeaderCell />
                        </TableHeader>
                        <TableBody>
                        @if (ViewModel.Player != null)
                        {
                            foreach (var item in ViewModel.Player.Inventory.GroupedItems)
                            {
                            <TableRow>
                                <TableRowCell>@item.Item.Name</TableRowCell>
                                <TableRowCell Class="text-center">@item.Quantity</TableRowCell>
                                <TableRowCell Class="text-center">@item.Item.Price</TableRowCell>
                                <TableRowCell Class="text-center">
                                    <Button id="sell-item-btn" Size="ButtonSize.Small" Color="Color.Secondary"
                                            Outline="true" Clicked="() => ViewModel.OnSellItem(item.Item)">
                                        Sell 1
                                    </Button>
                                </TableRowCell>
                            </TableRow>
                            }
                        }
                        </TableBody>
                    </Table>
                </Column>
                <Column ColumnSize="ColumnSize.Is6.OnWidescreen.Is12">
                    <div class="text-center">Trader's Inventory</div>
                    <Table Bordered="true" Narrow="true" Striped="true" Margin="Margin.Is2.OnY">
                        <TableHeader>
                            <TableHeaderCell>Name</TableHeaderCell>
                            <TableHeaderCell Class="text-center">Qty</TableHeaderCell>
                            <TableHeaderCell Class="text-center">Price</TableHeaderCell>
                            <TableHeaderCell />
                        </TableHeader>
                        <TableBody>
                        @if (ViewModel.Trader != null)
                        {
                            foreach (var item in ViewModel.Trader.Inventory.GroupedItems)
                            {
                            <TableRow>
                                <TableRowCell>@item.Item.Name</TableRowCell>
                                <TableRowCell Class="text-center">@item.Quantity</TableRowCell>
                                <TableRowCell Class="text-center">@item.Item.Price</TableRowCell>
                                <TableRowCell Class="text-center">
                                    <Button id="buy-item-btn" Size="ButtonSize.Small" Color="Color.Secondary"
                                            Outline="true" Clicked="() => ViewModel.OnBuyItem(item.Item)">
                                        Buy 1
                                    </Button>
                                </TableRowCell>
                            </TableRow>
                            }
                        }
                        </TableBody>
                    </Table>
                </Column>
            </Row>
        </ModalBody>
        <ModalFooter>
            <div style="margin: 0 auto">@ViewModel.ErrorMessage</div>
            <Button id="footer-close-btn" Color="Color.Secondary" Clicked="@modal.HideModal">Close</Button>
        </ModalFooter>
    </ModalContent>
</Modal>
}

@code {
    private SimpleRPG.Game.Helpers.ModalHelper modal = new Helpers.ModalHelper();

    [Parameter]
    public Trader? Trader { get; set; } = null;

    [Parameter]
    public Player? Player { get; set; } = null;

    [Parameter]
    public EventCallback InventoryChanged { get; set; }

    protected override void OnParametersSet()
    {
        ViewModel.Player = Player;
        ViewModel.Trader = Trader;
        ViewModel.InventoryChanged = InventoryChanged;
    }
}

To validate these changes, we can run our game again, move to a location with monsters (like the herbalist’s garden), and press Alt+K to attack over and over. This will have the exact same behavior as clicking the Attack button.

In conclusion, we reviewed a couple of different ways to handle keyboard events in our game. We can process the keys in code when we have specific needs, like arrow keys. Or we can use accelerators/access keys to click elements like buttons (without having to write any special code).

Note: if the focus gets moved to a different element, the game will no longer respond to keyboard events… they are now going to a different element (like an edit control). If you click on the game screen background, that should return focus to the root element and process keys again.

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 )

Google photo

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

Twitter picture

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

Facebook photo

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

Connecting to %s