(#100DaysOfCode) – Week 2 (Days 7-13) – Converastion’s (Not Yet) Done

As I said in an earlier blog entry, I like Japanese heavy metal bands with female singers. Unfortunately, I don’t understand Japanese, despite numerous attempts to start learning the language. So very few Japanese metal bands sing in English so when one does its a pleasant surprise. One such band is Soundwitch. I’ve been aware of them for some time (mainly via YouTube) but yesterday they have announced they are giving away their first three albums for free. This ends on 24 February 2019 so grab your copies now whilst you still can.

Since these blog posts are going to be done weekly now I won’t include all the code changes between each week. You can see the code by cloning and pulling from my git repo on BitBucket.

So I have been looking to put the web app on a publically facing internet site but I can’t really afford a paid service. Unfortunately there seem to be no free ASP.NET Core hosting services out there. I have signed up for a free PHP based service (I haven’t put any content up yet) so I might try doing this in PHP. I may also add a windows desktop UI. However, .NET core web apps can be self-running when published. Unfortunately, for some reason, I can’t publish the app and get the minified CSS to work so I adjusted the layout file to use the unminified CSS in Release. You can use the following links to download the current publish version. There isn’t much functionality yet.

After extracting run the EXE in the directory and then launch the browser and point to the address stated in the command window.

Getting Talkative

So after some head scratching and trying to come up with a design plan I threw together a simple scriptable dialogue system for character-character interaction. It’s not perfect and will certainly change and evolve over time but it currently stands as an O.K. proof of concept.

The conversations are stored in the assembly resource in a resource directory called CharacterConversations and are keyed by CharacterId. I have extended the IGameResourceRepository interface and associated class to add a method to retrieve the conversation object for a given character id. For this game, dialog will only occur between the player and a single character at a time. I doubt I will add NPC to NPC conversations for this one.

A CharacterConversation consists of a CharacterId and a list of Dialogs. The Dialogs themselves hold the actual conversation points, consisting of the character’s speech and a list of possible player responses. Each response can lead to a series of in-game actions occurring (not yet implemented) followed by a pointer to the next piece of dialog to process if the converstation is to continue:-

using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class CharacterConversation
    {
        public string CharacterId { get; set; }

        public List<CharacterDialog> Dialogs { get; set; }
    }
}
 namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class CharacterDialog
    {
        public string DialogId { get; set; }

        public List<DialogText> DialogTexts { get; set; }

        public List<PlayerResponse> PlayerResponses { get; set; }
    }
}

The DialogTexts collection contains all the possible opening Dialogs that can be displayed. The PreConditions is a list of scriptable conditions that must be met for that DialogText to be chosen for display. ALL of the conditions in the list must evaluate to true for that particular DialogText to be displayed. The DialogEngine will run through all of the DialogText objects and pick the first one who’s Preconditions parse successfully.

using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class DialogText
    {
        public string Id { get; set; }
        public List<string> Texts { get; set; }
        public List<string> Preconditions { get; set; }
    }
}

The PlayerResponses contains all the possible player responses that can be displayed. It inherits from the DialogText object so also contains a set of scriptable PreConditions. The difference here is that ALL PlayerResponses that pass their preconditions will be outputted to the player as possible responses to the chosen DialogText.

using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class PlayerResponse : DialogText
    {
        public List<string> ResponseActions { get; set; }
        public string NextDialogId { get; set; }
    }
}

The ResponseActions are a list of scriptable actions that will be performed within the GameEngine when the given response is chosen. This will be implemented in a later commit. The NextDialogId points to the next Dialog that will be displayed. If this is empty then the conversation immediately ends. Also if the Dialog object has no valid PlayerResponses then the conversation also ends after the text for that Dialog is displayed.

Script For A Jester’s Developer’s Tear

The scripting engine at the moment is quite basic and only the preconditions are presently scripted. I created a Parser directory in which to hold all the objects for the Parser. The code for the main GameScriptParser is here:-

using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using System.Collections.Generic;
using System.Linq;
using Cybermancer.CelestialAberration.GameEngine.Exceptions;

namespace Cybermancer.CelestialAberration.GameEngine.Parser
{
    public class GameScriptParser
    {
        private GameData _gameData;

        public GameScriptParser(GameData gameData)
        {
            _gameData = gameData;
        }

        public bool ParseConditions(List<string> texts)
        {
            if (texts.Count < 1)
            {
                return true;
            }

            foreach (string text in texts)
            {
                if (!ParseCondition(text))
                {
                    return false;
                }
            }

            return true;
        }

        public bool ParseCondition(string text)
        {
            List<LexerToken> tokens = Lexer.ProcessText(text);

            TokenPropertyValue lhs = null;

            var conditional = string.Empty;

            TokenPropertyValue rhs = null;

            var currentIndex = 0;

            while (currentIndex < tokens.Count)
            {
                var token = tokens[currentIndex];

                if (!string.IsNullOrWhiteSpace(token.Text))
                {

                    switch (token.Type)
                    {
                        case TokenType.ConditionalOperator:
                            if (lhs == null)
                            {
                                throw new GameEngineException(ErrorMessageHelper.GetParserError("PreCondition parser", text, "operator found without LHS token."));
                            }
                            conditional = token.Text;
                            break;
                        case TokenType.GameDataProperty:
                            if (lhs == null)
                            {
                                lhs = GetGameDataProperty(token.Text);
                            }
                            else
                            {
                                rhs = GetGameDataProperty(token.Text);
                            }
                            break;
                        case TokenType.PropertyValue:
                            if (lhs == null)
                            {
                                lhs = new TokenPropertyValue(token.Text);
                            }
                            else
                            {
                                rhs = new TokenPropertyValue(token.Text);
                            }

                            break;
                    }
                }

                currentIndex++;
            }

            if (lhs == null)
            {
                throw new GameEngineException(ErrorMessageHelper.GetParserError("PreCondition parser", text, "LHS token not found."));
            }

            if (string.IsNullOrEmpty(conditional))
            {
                throw new GameEngineException(ErrorMessageHelper.GetParserError("PreCondition parser", text, "operator token not found."));
            }

            if (rhs == null)
            {
                throw new GameEngineException(ErrorMessageHelper.GetParserError("PreCondition parser", text, "RHS token not found."));
            }

            if (lhs.DataType == rhs.DataType)
            {
                switch (lhs.DataType)
                {
                    case TokenPropertyType.Bool:
                        return BoolComparison(lhs.Text, conditional, rhs.Text);
                    case TokenPropertyType.Decimal:
                        return DecimalComparison(lhs.Text, conditional, rhs.Text);
                    case TokenPropertyType.Int:
                        return IntegerComparison(lhs.Text, conditional, rhs.Text);
                    default:
                        return Compare<string>(lhs.Text, conditional, rhs.Text);
                }
            }
            else
            {
                if (lhs.DataType == TokenPropertyType.String || rhs.DataType == TokenPropertyType.String)
                {
                    return Compare<string>(lhs.Text, conditional, rhs.Text);
                }
                else
                {
                    return DecimalComparison(lhs.Text, conditional, rhs.Text);
                }
            }
        }

        private bool BoolComparison(string lhs, string conditional, string rhs)
        {
            var lhsValue = bool.Parse(lhs);
            bool rhsValue = bool.Parse(rhs);

            return Compare<bool>(lhsValue, conditional, rhsValue);
        }

        private bool DecimalComparison(string lhs, string conditional, string rhs)
        {
            var lhsValue = DecimalConversion(lhs);
            var rhsValue = DecimalConversion(rhs);
            
            return Compare<decimal>(lhsValue, conditional, rhsValue);
        }

        private bool IntegerComparison(string lhs, string conditional, string rhs)
        {
            var lhsValue = int.Parse(lhs);
            var rhsValue = int.Parse(rhs);

            return Compare<int>(lhsValue, conditional, rhsValue);
        }

        private bool Compare<T>(T lhs, string conditional, T rhs)
        {
            var comparer = Comparer<T>.Default;

            switch (conditional)
            {
                case "==":
                    return comparer.Compare(lhs,rhs) == 0;
                case "<":
                    return comparer.Compare(lhs, rhs) < 0;
                case ">":
                    return comparer.Compare(lhs, rhs) > 0;
                case "!=":
                    return comparer.Compare(lhs, rhs) != 0;
                default:
                    return false;
            }
        }

        private decimal DecimalConversion(string text)
        {
            if (text.ToLowerInvariant() == "tue")
            {
                return 1.00M;
            }
            else if (text.ToLowerInvariant() == "false")
            {
                return 0.00M;
            }
            else
            {
                return decimal.Parse(text);
            }
        }

        private TokenPropertyValue GetGameDataProperty(string propertyText)
        {
            var propertyNames = propertyText.Split('.').ToList();

            var currentIndex = 0;

            object currentProperty = _gameData;

            while (currentIndex < propertyNames.Count)
            {
                if (currentIndex > 0)
                {
                    currentProperty = currentProperty.GetType().GetProperty(propertyNames[currentIndex]).GetValue(currentProperty, null);

                    if (currentProperty == null)
                    {
                        throw new GameEngineException(ErrorMessageHelper.GetParserError("PreCondition parser",
                            propertyText, $"{propertyNames[currentIndex]} property not found."));
                    }
                }

                currentIndex++;
            }

            return new TokenPropertyValue(currentProperty.ToString());
        }
    }
}

The lines of text are first passed through a Lexer class that creates a series of tokens. These tokens identify what type of data each ‘word’ in the text is. Currently this is either a GameData object property, a literal value or a conditional operator.

 using System;
using System.Collections.Generic;
using System.Linq;

namespace Cybermancer.CelestialAberration.GameEngine.Parser
{
    public class Lexer
    {
        public static List<LexerToken> ProcessText(string text)
        {
            var tokenTexts = text.Split(' ');

            return tokenTexts.Where(tokenText => !String.IsNullOrWhiteSpace(tokenText))
                .Select(tokenText => new LexerToken()
                    {
                        Text = tokenText,
                        Type = GetTokenType(tokenText)
                    }).ToList();
        }

        public static TokenType GetTokenType(string text)
        {
            if (text.StartsWith("GameData"))
            {
                return TokenType.GameDataProperty;
            }

            if (text == "==" || text == "<" || text == ">" || text == "!=")
            {
                return TokenType.ConditionalOperator;
            }

            return TokenType.PropertyValue;
        }
    }
}

The parser then takes these tokens and reduces them to a left hand side value, and operator and a right hand side value. It then runs a method that compares the lhs and rhs values using the correct comparison operator and returns true or false as the comparison result.

The Parser is used by a DialogEngine (in the Engine directory) to reduce a requested conversation entry into a simpler DialogOutput Class that returns all of the data necessary for further display or processing. This engine also sets the CurrentDialogId and CurrentDialogTextId on the GameData object to hold the dialog currently in progress:-

using System.Collections.Generic;
using System.Linq;
using Cybermancer.CelestialAberration.GameEngine.Exceptions;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Cybermancer.CelestialAberration.GameEngine.Parser;
using Cybermancer.CelestialAberration.GameEngine.Repositories;

namespace Cybermancer.CelestialAberration.GameEngine.Engine
{
    public class DialogEngine
    {
        private GameData _gameData;
        private readonly IGameResourceRepository _resourceRepository;

        public DialogEngine(GameData gameData, IGameResourceRepository resourceRepository)
        {
            _gameData = gameData;
            _resourceRepository = resourceRepository;
        }

        public DialogOutput ProcessDialog(string characterId, string dialogId)
        {
            var conversation = _resourceRepository.GetConversation(_gameData.Language, characterId);

            var dialog = conversation.Dialogs.FirstOrDefault(dialogItem => dialogItem.DialogId == dialogId);

            if (dialog == null)
            {
                throw new GameEngineException(ErrorMessageHelper.GetDialogNotFoundError(characterId, dialogId));
            }

            var dialogOutput = new DialogOutput()
            {
                Id = dialogId,
                OutputText = GetOutputtedDialogText(characterId, dialog),
                OutputResponses = GetOutputResponses(characterId, dialog)
            };

            _gameData.CurrentDialogId = dialogOutput.Id;
            _gameData.CurrentDialogTextId = dialogOutput.OutputText.Id;

            return dialogOutput;
        }

        private DialogOutputText GetOutputtedDialogText(string characterId, CharacterDialog dialog)
        {
            var dialogTextToOutput =
                dialog.DialogTexts.FirstOrDefault(dialogText => ProcessPreconditions(dialogText.Preconditions));

            if (dialogTextToOutput == null)
            {
                throw new GameEngineException(ErrorMessageHelper.GetDialogTextNotFoundError(characterId, dialog.DialogId));
            }

            return new DialogOutputText()
            {
                Id = dialogTextToOutput.Id,
                Texts = dialogTextToOutput.Texts
            };
        }

        private List<DialogOutputResponse> GetOutputResponses(string characterId, CharacterDialog dialog)
        {
            return dialog.PlayerResponses.Where(response => ProcessPreconditions(response.Preconditions)).Select(response => new DialogOutputResponse()
            {
                Id = response.Id,
                Texts = response.Texts
            }).ToList();
        }

        private bool ProcessPreconditions(List<string> preconditions)
        {
            var parser = new GameScriptParser(_gameData);

            return parser.ParseConditions(preconditions);
        }
    }
}
using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class DialogOutput
    {
        public string Id { get; set; }

        public DialogOutputText OutputText { get; set; }

        public List<DialogOutputResponse> OutputResponses { get; set; }
    }
}
using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class DialogOutputText
    {
        public string Id { get; set; }

        public List<string> Texts { get; set; }
    }
}
using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class DialogOutputResponse
    {
        public string Id { get; set; }

        public List<string> Texts { get; set; }
    }
}

I have also added unit tests around the new code added. If you have any questions just contact me via the contact page on this blog or leave a comment.

Next week I should have implemented processing of player responses and proper parsing of dialog texts. I should also have something visible in the web UI.



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

This site uses Akismet to reduce spam. Learn how your comment data is processed.