(#100DaysOfCode) – Week 3 (Days 14-25)

I am a little late with this week’s blog entry. I had issues getting the parser to work and have been busy with another project that I was working on that is now available on itch.io:-

Isolda’s Eyes
by Cybermancer
Text based horror game written in Twine
Play on Itch.Io

Sticking the boot in

Firstly, I have tidied up the web UI a bit and made use of Bootstrap. Bootstrap is a client-side framework for building web pages. It makes styling and layout easy, and there are a variety of themes you can use with it. The .Net Core MVC project comes with Bootstrap 3 preinstalled, but I’ve downloaded Bootstrap 4 which is the latest version at the time of writing. I have also applied a free custom bootstrap theme called Darkster.

Displaying the conversations

In last weeks blog post we had the dialog engine spit out a DialogOutput object, and this is what will be used as part of a ShowDialogViewModel that will become part of the output of a ShowDialog action and a ProcessDialogResponse action on a new GameEngine controller.

using Cybermancer.CelestialAberration.GameEngine.Engine;
using Cybermancer.CelestialAberration.GameEngine.Exceptions;
using Cybermancer.CelestialAberration.GameEngine.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Models;
using CyberMancer.CelestialAberration.WebApplication.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;

namespace CyberMancer.CelestialAberration.WebApplication.Controllers
{
    public class GameEngineController : Controller
    {
        private readonly IHostingEnvironment _hostingEnvironment;

        public GameEngineController(IHostingEnvironment hostingEnvironment)
        {
            _hostingEnvironment = hostingEnvironment;
        }

        public IActionResult ShowDialog(string gameId, string characterId, string dialogId)
        {
            IGameDataRepository gameDataRepository = new GameDataRepository(_hostingEnvironment);

            try
            {
                var gameData = gameDataRepository.GetGameData(gameId);

                if (gameData == null)
                {
                    return RedirectToAction("ManageSaves", "Game", new { errorMessage = "Unable to load game!" });
                }

                var navigationService = new NavigationService(_hostingEnvironment);

                var dialogEngine = new DialogEngine(gameData, new GameResourceAssemblyRepository());

                var dialog = dialogEngine.ProcessDialog(characterId, dialogId);

                gameDataRepository.StoreGameData(gameData);

                return View(new ShowDialogViewModel()
                {
                    GameId = gameData.Id,
                    DialogOutput = dialog,
                    ErrorMessage = string.Empty,
                    Navigation = navigationService.GetNavigationModel("inGame")
                });
            }
            catch (GameEngineException e)
            {
                return RedirectToAction("ManageSaves", "Game", new { errorMessage = e.ErrorMessage.userMessage });
            }
        }

        public IActionResult ProcessDialogResponse(string gameId, string responseId)
        {
            IGameDataRepository gameDataRepository = new GameDataRepository(_hostingEnvironment);

            try
            {
                var gameData = gameDataRepository.GetGameData(gameId);

                if (gameData == null)
                {
                    return RedirectToAction("ManageSaves", "Game", new { errorMessage = "Unable to load game!" });
                }

                var navigationService = new NavigationService(_hostingEnvironment);

                var dialogEngine = new DialogEngine(gameData, new GameResourceAssemblyRepository());

                var dialog = dialogEngine.ProcessDialogResponse(responseId);

                gameDataRepository.StoreGameData(gameData);

                if (dialog == null)
                {
                    return Ok("Dialog ended");
                }
                else
                {
                    return RedirectToAction("ShowDialog", "GameEngine", new { gameId = gameId, characterId = gameData.CurrentCharacterId, dialogId = dialog.Id });
                }
            }
            catch (GameEngineException e)
            {
                return RedirectToAction("ManageSaves", "Game", new {errorMessage = e.ErrorMessage.userMessage});
            }
        }
    }
}
using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class ShowDialogViewModel : BasePageModel
    {
        public string GameId { get; set; }

        public DialogOutput DialogOutput { get; set; }
    }
}

The view itself is quite simple. It spits out the output text of the OutputDialog along with each PlayerOutputResponse returned.

<h2>@ViewData["Title"]</h2>
<span class="text-danger">@Model.ErrorMessage</span>
<div>
    @Html.Raw(string.Join(" ", Model.DialogOutput.OutputText.Texts))
    <br/><br/>
</div>
<div>
    @if (Model.DialogOutput?.OutputResponses != null && Model.DialogOutput.OutputResponses.Count > 0)
    {
        <ul>
        @foreach (var response in Model.DialogOutput.OutputResponses)
         {
            <li>
                @using (Html.BeginForm("ProcessDialogResponse", "GameEngine", FormMethod.Post))
                {
                    @Html.HiddenFor(x => x.GameId)
                    <input type="hidden" id="responseid" name="responseId" value="@response.Id"/>
                    <button type="submit" class="btn btn-secondary">@Html.Raw(string.Join(" ", response.Texts))</button><br/><br/>
                }
             </li>
         }
        </ul>
    }
</div>

Please respond

The next part of code needed was the processing of player responses. This includes an update to the parser in order to parse an action script.

using System.Collections.Generic;
using System.Linq;
using Cybermancer.CelestialAberration.GameEngine.Exceptions;
using Cybermancer.CelestialAberration.GameEngine.Extensions;
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;
        }

        public DialogOutput ProcessDialogResponse(string responseId)
        {
            var conversation = _resourceRepository.GetConversation(_gameData.Language, _gameData.CurrentCharacterId);

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

            var outputResponse = dialog.PlayerResponses.FirstOrDefault(response => response.Id == responseId);

            ProcessResponseActions(outputResponse);

            _gameData.LastResponseId = responseId;

            return ProcessDialog(_gameData.CurrentCharacterId, outputResponse.NextDialogId);
        }

        private void ProcessResponseActions(PlayerResponse response)
        {
            var parser = new GameScriptParser(_gameData);

            parser.ExecuteActionScripts(response.ResponseActions);
        }

        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.Select(text => ParseDialogText(text, characterId)).ToList()
            };
        }

        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.Select(text => ParseDialogText(text, characterId)).ToList()
            }).ToList();
        }

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

            return parser.ParseConditions(preconditions);
        }

        private string ParseDialogText(string text, string characterId)
        {
            var character = _gameData.Npcs.FirstOrDefault(npc => npc.CharacterId == characterId);
            return text.Replace("[PLAYER.", "[Prop.").InsertProperties(_gameData.PlayerCharacter).Replace("[CHARACTER.", "[Prop.").InsertProperties(character).ToHtml();
        }
    }
}
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;
            }

            if (MacroParser.AvailableMacros.Contains(text))
            {
                return TokenType.Macro;
            }

            return TokenType.PropertyValue;
        }
    }
}
namespace Cybermancer.CelestialAberration.GameEngine.Parser
{
    public enum TokenType
    {
        Ignore,
        GameDataProperty,
        ConditionalOperator,
        PropertyValue,
        Macro
    }
}
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 void ExecuteActionScripts(List<string> texts)
        {
            foreach (string text in texts)
            {
                ExecuteActionScript(text);
            }
        }

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

            foreach (LexerToken token in tokens)
            {
                if (token.Type == TokenType.Macro)
                {
                    var macroParser = new MacroParser(_gameData);
                    macroParser.ExecuteMacro(token.Text);
                }
            }
        }

        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());
        }
    }
}
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using System.Collections.Generic;

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

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

        public static List<string> AvailableMacros
        {
            get
            {
                return new List<string>()
                {
                    "HaveSex"
                };
            }
        }

        public void ExecuteMacro(string text)
        {
            switch (text)
            {
                case "HaveSex":
                    HaveSex();
                    break;
            }
        }

        private void HaveSex()
        {
            var character = _gameData.GetCurrentCharacter();

            if (character != null)
            {
                var charRelationship = character.GetRelationship(_gameData.PlayerCharacter.CharacterId);
                charRelationship.SexaulRelations = true;

                var playerRelationship = _gameData.PlayerCharacter.GetRelationship(_gameData.CurrentCharacterId);
                playerRelationship.SexaulRelations = true;
            }
        }
    }
}

The ProcessDialogResponse action method of the GameEngineController calls the new ProcessDialogResponse method on the DialogEngine. This takes the response Id and grabs the PlayerResponse object. It then parses and runs any ActionScript texts in the ResponseActions collection of the PlayerResponse object. Finally, it uses the NextDialogId property of the PlayerResponse to generate a new DialogOutput.

Let’s have sex

The lexer now has a new type of token to process – a macro. A macro token will be a single word command that will do common tasks.
There is currently only one macro – HaveSex. Before you jump to assumptions, this game will not have explicit sexual content, but there will be ‘fade to black’ scenes. I have introduced a mechanism to track when characters have had sexual relations and as the game evolves this can be used, in part, to determine how characters interact with each other throughout the course of the game.

The macro itself is executed as a method in the MacroParser. In this case it just pulls the relationship data for both the player and the target character and updates the SexualRelations property of each.

The final result

The GameEngineController will finally recieve another DialogOutput model which it will then use to redirect to the ShowDialog action and view which then displays the next part of the dialog.

That’s it for this week. As usual the source code is on Bitbucket. A demo of the application in its current state can be downloaded from here.

One thought on “(#100DaysOfCode) – Week 3 (Days 14-25)

  1. Interesting project. I’ve made a few attempts at designing RPGs (console-based) but never got very far. It would be good to have a coding challenge like this to keep me going even when I get bored with a project, which happens all too often. ADHD is a bitch.

    Like

Leave a Reply to psychocod3r Cancel 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.