(#100DaysOfCode) Days Five and Six – Somebody Save Me

Funnily enough the above is the title of a very good song by Krypteria, but we aren’t talking about that today. We are talking about game saving and loading.

Also, a note to any readers. After today this blog will be updated on a weekly rather than a(n) (almost) daily basis. I’ll still tweet about my progress and there will still be regular commits to the bit bucket repo, however.

Firstly, I have made some changes to the GameDataRepository class.

using System;
using System.IO;
using Cybermancer.CelestialAberration.GameEngine.Exceptions;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Cybermancer.CelestialAberration.GameEngine.Repositories;
using Microsoft.AspNetCore.Hosting;
using Newtonsoft.Json;

namespace CyberMancer.CelestialAberration.WebApplication.Repositories
{
    public class GameDataRepository : IGameDataRepository
    {
        private readonly string _gameDataDirectory;

        private readonly string _indexFilePath;

        public GameDataRepository(IHostingEnvironment hostingEnvironment)
        {
            _gameDataDirectory = Path.Combine(hostingEnvironment.ContentRootPath, "Games");
            _indexFilePath = Path.Combine(_gameDataDirectory, "index.json");
        }

        public void DeleteGameData(GameData data) => DeleteGameData(data.Id);

        public void DeleteGameData(string gameId)
        {
            var filePath = Path.Combine(_gameDataDirectory, $"{gameId}.json");
            if (File.Exists(filePath))
            {
                try
                {
                    File.Delete(filePath);
                    RemoveGameFromCatalog(gameId);
                }
                catch (Exception e)
                {
                    throw new GameEngineException(ErrorMessageHelper.GetFileOperationError(gameId, "delete the game"), e);
                }
            }
            else
            {
                throw new GameEngineException(ErrorMessageHelper.GetGameDataNotFoundError(gameId, "deleted"));
            }
        }

        public GameData GetGameData(string id)
        {
            try
            {
                var gameIndex = LoadGameIndex();

                var gameInfo = gameIndex.GetGameInfo(id);

                if (gameInfo == null)
                {
                    throw new GameEngineException(ErrorMessageHelper.GetGameDataNotFoundError(id, "loaded"));
                }

                return LoadGameData(gameInfo.Id);
            }
            catch (GameEngineException)
            {
                throw;
            }
            catch (Exception e)
            {
                throw new GameEngineException(ErrorMessageHelper.GetFileOperationError(id, "load the game"), e);
            }
        }

        public GameIndex GetGameIndex()
        {
            try
            {
                return LoadGameIndex();
            }
            catch (Exception e)
            {
                throw new GameEngineException(ErrorMessageHelper.GetFileOperationError("{game index}", "load the game index"), e);
            }
            
        }

        public void StoreGameData(GameData data)
        {
            try
            {
                SaveGameData(data);

                var gameIndex = LoadGameIndex();

                gameIndex.StoreGameInfo(new GameDataInfo()
                {
                    Id = data.Id,
                    Language = data.Language,
                    LastModified = data.LastModified,
                    PlayerCharacter = data.PlayerCharacter
                });

                SaveGameIndex(gameIndex);
            }
            catch (Exception e)
            {
                throw new GameEngineException(ErrorMessageHelper.GetFileOperationError(data.Id, "save the game"), e);
            }
        }

        private GameIndex LoadGameIndex()
        {
            if (File.Exists(_indexFilePath))
            {
                return JsonConvert.DeserializeObject<GameIndex>(File.ReadAllText(_indexFilePath));
            }
            else
            {
                return new GameIndex();
            }
        }

        private void RemoveGameFromCatalog(string id)
        {
            var gameIndex = LoadGameIndex();

            gameIndex.RemoveGameInfo(id);

            SaveGameIndex(gameIndex);
        }

        private void SaveGameIndex(GameIndex gameIndex)
        {
            CreateDirectoryIfNotExists();
            File.WriteAllText(_indexFilePath, gameIndex.ToString());
        }

        private GameData LoadGameData(string id)
        {
            var filePath = Path.Combine(_gameDataDirectory, $"{id}.json");
            if (File.Exists(filePath))
            {
                var gameDataAsString = File.ReadAllText(filePath);
                return JsonConvert.DeserializeObject<GameData>(gameDataAsString);
            }
            else
            {
                throw new GameEngineException(ErrorMessageHelper.GetGameDataNotFoundError(id, "loaded"));
            }
        }

        private void SaveGameData(GameData data)
        {
            CreateDirectoryIfNotExists();
            var filePath = Path.Combine(_gameDataDirectory, $"{data.Id}.json");

            File.WriteAllText(filePath, data.ToString());
        }

        private void CreateDirectoryIfNotExists()
        {
            if (!Directory.Exists(_gameDataDirectory))
            {
                Directory.CreateDirectory(_gameDataDirectory);
            }
        }
    }
}

You will notice I marked the two private properties as read-only since we only set these during instantiation and they don’t change at all after that. I also added some code to create the game directory if it doesn’t already exist prior to saving anything. This makes the repository more robust. I added a new method to delete a game by its id alone so the calling assembly doesn’t need a full gameData object before calling delete. The most significant change, however, is the addition of an error handling mechanism that makes use of a GameEngineException class to return both developer-friendly and user-friendly standardised errors when something goes wrong:-

namespace Cybermancer.CelestialAberration.GameEngine.Exceptions
{
    public class ErrorMessage
    {
        public string errorCode { get; set; }

        public string errorMessage { get; set; }

        public string userMessage { get; set; }
    }
}
namespace Cybermancer.CelestialAberration.GameEngine.Exceptions
{
    public class ErrorMessageHelper
    {
        public static ErrorMessage GetGameDataNotFoundError(string gameId, string operationPerformed)
        {
            return new ErrorMessage()
            {
                errorCode = "GAMEDATANOTFOUND",
                errorMessage = $"Game data for id {gameId} not found.",
                userMessage = $"The game could not be {operationPerformed}."
            };
        }

        public static ErrorMessage GetFileOperationError(string gameId, string operationPerformed)
        {
            return new ErrorMessage()
            {
                errorCode = "FILEIOERROR",
                errorMessage = $"File operation exception for id {gameId}.",
                userMessage = $"An error occured when attempting to {operationPerformed}."
            };
        }
    }
}
using System;

namespace Cybermancer.CelestialAberration.GameEngine.Exceptions
{
    public class GameEngineException : Exception
    {
        private ErrorMessage _errorMessage;

        public ErrorMessage ErrorMessage => _errorMessage;

        public GameEngineException(ErrorMessage errorMessage) : base(errorMessage.ToString())
        {
            _errorMessage = errorMessage;
        }

        public GameEngineException(ErrorMessage errorMessage, Exception innerException) : base(errorMessage.ToString(), innerException)
        {
            _errorMessage = errorMessage;
        }
    }
}

The result of this is that any UI code can consume the GameEngineException thrown and use the ErrorMessage.UserMessage property to return a user-friendly error message to the browser, whilst internally logging the developer-friendly message to an error trace file, along with the exception stack trace. I will look into error logging at a later date, but for the MVC application, I will probably use the excellent ELMAH for this. For everything else there’s NLog.

The next change is in the UI and I have implemented the use of a NavigationModel and a BasePageModel for views.

using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class BasePageModel : SerializableGameObject
    {
        public NavigationModel Navigation { get; set; }

        public string ErrorMessage { get; set; }
    }
}
using System.Collections.Generic;
using Newtonsoft.Json;

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class NavigationModel
    { 
        public List<string> ActiveMenus { get; set; }

        public override string ToString()
        {
            return JsonConvert.SerializeObject(ActiveMenus).Replace("\"", "'");
        }
    }
}

The NavigationModel is used to control the main navigation links. For example, if there are no games to load then the Load Game menu will not be displayed. In order to implement this I have made use of the very useful Vue.js.

After downloading the latest normal and minified versions of vue.js I added them to the _Layout.cshtml

<body>
    <environment include="Development">
        <script src="~/lib/jquery/dist/jquery.js"></script>
        <script src="~/lib/bootstrap/dist/js/bootstrap.js"></script>
        <script src="~/js/vue-2.5.22.js"></script>
        <script src="~/js/site.js" asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-3.3.1.min.js"
                asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
                asp-fallback-test="window.jQuery"
                crossorigin="anonymous"
                integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT">
        </script>
        <script src="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/bootstrap.min.js"
                asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js"
                asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
                crossorigin="anonymous"
                integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa">
        </script>
        <script src="~/js/vue.min-2.5.22.js"></script>
        <script src="~/js/site.min.js" asp-append-version="true"></script>
    </environment>

The scripts go below jQuery as Vue relies on this. The next change was to rip out the navigation HTML and put it inside its own partial view. So the _Layout.cshtml becomes:

<nav class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a asp-area="" asp-controller="Home" asp-action="Index" class="navbar-brand">Celestial Aberration by CyberMancer1927</a>
            </div>
            <div class="navbar-collapse collapse">
                @RenderSection("Navigation", required: false)
            </div>
        </div>
    </nav>

The Navigation.cshtml file I created:

<ul id="topNavigation" class="nav navbar-nav">
    <li v-if="activeMenus.includes('intro')"><a asp-area="" asp-controller="Home" asp-action="Index">Intro</a></li>
    <li v-if="activeMenus.includes('new')"><a asp-area="" asp-controller="Game" asp-action="New">New Game</a></li>
    <li v-if="activeMenus.includes('load')"><a asp-area="" asp-controller="Game" asp-action="ManageSaves">Load Existing Game</a></li>
    <li v-if="activeMenus.includes('about')"><a asp-area="" asp-controller="Home" asp-action="About">About</a></li>
    <li v-if="activeMenus.includes('contact')"><a asp-area="" asp-controller="Home" asp-action="Contact">Contact</a></li>
</ul>
<script type="text/javascript">
    var topNav = new Vue({
        el: '#topNavigation',
        data: {
            activeMenus: @Html.Raw(Model.ToString())
        }
    });
</script>

This is a very simplistic use of vue.js to show or hide HTML elements based on the model properties. I will be using vue.js in a more complex way in the later iterations of the application.

I renamed the GameModel class to GameIntroModel and derived it from BasePageModel.

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class GameIntroModel : BasePageModel
    {
        public List<TransitionTextModel> IntroTexts { get; set; }
    }
}
using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class TransitionTextModel : TransitionText
    {
        public string ContentHtml { get; set; }
    }
}

This object is populated by a service class:-

using Cybermancer.CelestialAberration.GameEngine.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Models;
using System.Collections.Generic;
using System.Linq;
using Cybermancer.CelestialAberration.GameEngine.Extensions;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Microsoft.AspNetCore.Hosting;

namespace CyberMancer.CelestialAberration.WebApplication.Services
{
    public class GameIntroService
    {
        private readonly IHostingEnvironment _hostingEnvironment;

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

        public GameIntroModel GetGameIntroModel()
        {
            return new GameIntroModel()
            {
                IntroTexts =  GetIntroTexts(),
                Navigation = GetNavigation()
            };
        }

        private List<TransitionTextModel> GetIntroTexts()
        {
            IGameResourceRepository gameResourceRepository = new GameResourceAssemblyRepository();

            var gameIntro = gameResourceRepository.GetGameIntro("Default");

            var texts = gameIntro.IntroTexts.Select(text => MapToModel(text)).ToList();

            return texts ?? new List<TransitionTextModel>();
        }

        private TransitionTextModel MapToModel(TransitionText text)
        {
            return new TransitionTextModel()
            {
                GameTextId = text.GameTextId,
                Content = text.Content,
                SecondsToDisplay = text.SecondsToDisplay,
                Style = text.Style,
                ContentHtml = text.Content.ToHtml()
            };
        }

        private NavigationModel GetNavigation()
        {
            var navigationService = new NavigationService(_hostingEnvironment);

            return navigationService.GetNavigationModel("Index");
        }
    }
}

Which in turn uses a NavigationService to populate the NavigationModel onto the page model:-

using Microsoft.AspNetCore.Hosting;
using System.Collections.Generic;
using Cybermancer.CelestialAberration.GameEngine.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Models;
using CyberMancer.CelestialAberration.WebApplication.Repositories;

namespace CyberMancer.CelestialAberration.WebApplication.Services
{
    public class NavigationService
    {
        private readonly IHostingEnvironment _hostingEnvironment;

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

        public NavigationModel GetNavigationModel(string view)
        {
            var navigation = new NavigationModel()
            {
                ActiveMenus = new List<string>()
            };

            switch (view)
            {
                case "newgame":
                    navigation.ActiveMenus.Add("intro");

                    if (SavedGamesExist())
                    {
                        navigation.ActiveMenus.Add("load");
                    }

                    navigation.ActiveMenus.Add("about");
                    navigation.ActiveMenus.Add("contact");
                    break;
                case "gameselection":
                    navigation.ActiveMenus.Add("intro");
                    navigation.ActiveMenus.Add("about");
                    navigation.ActiveMenus.Add("contact");
                    break;
                default:
                    navigation.ActiveMenus.Add("intro");
                    navigation.ActiveMenus.Add("new");

                    if (SavedGamesExist())
                    {
                        navigation.ActiveMenus.Add("load");
                    }

                    navigation.ActiveMenus.Add("about");
                    navigation.ActiveMenus.Add("contact");
                    break;
            }

            return navigation;
        }

        private bool SavedGamesExist()
        {
            IGameDataRepository gameRepository = new GameDataRepository(_hostingEnvironment);

            var index = gameRepository.GetGameIndex();

            return index.GameCatalog.Count > 0;
        }
    }
}

In the home controller I added a constructor that will take an IHostingEnvironment. This object will automatically be injected into the controller by ASP.Net core. The ActionMethods have been modified to add page models containing navigation data to send to their respective views.

using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using CyberMancer.CelestialAberration.WebApplication.Models;
using Microsoft.AspNetCore.Hosting;
using CyberMancer.CelestialAberration.WebApplication.Services;

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

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

        public IActionResult Index()
        {
            var service = new GameIntroService(_hostingEnvironment);

            return View(service.GetGameIntroModel());
        }

        public IActionResult About()
        {
           return View(GetBasePageModel("About"));
        }

        public IActionResult Contact()
        {
            return View(GetBasePageModel("Contact"));
        }

        public IActionResult Privacy()
        {
            return View();
        }

        [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }

        private BasePageModel GetBasePageModel(string view)
        {
            var service = new NavigationService(_hostingEnvironment);

            return new BasePageModel()
            {
                Navigation = service.GetNavigationModel(view)
            };
        }
    }
}

I also changed the home views to render the new navigation section and any error messages.

@model CyberMancer.CelestialAberration.WebApplication.Models.GameIntroModel

@{
    ViewData["Title"] = "Home Page";
}

@section Navigation
{
    <partial name="Navigation" for="Navigation" />
}
<div class="glitch">
    <div id="introText" class="neon">
    </div>
</div>

<script src="~/js/intro.js" type="text/javascript"></script>

<script type="text/javascript">
    $(document).ready(function () {
        var gameInfo = @Html.Raw(@Model.ToString());
        StartIntro(gameInfo);
    });
</script>

I also made some slight changes to the intro.js file as we are now doing text processing of the intro text server side before we hit the view.

function StartIntro(gameIntro) {
    ShowTransition(gameIntro, 0);
}

function getTextItem(gameIntro, index) {
    var textId = "INTRO_" + index;

    for (textItemIndex in gameIntro.IntroTexts) {
        if (gameIntro.IntroTexts[textItemIndex].GameTextId === textId) {
            return gameIntro.IntroTexts[textItemIndex];
        }
    }

    return null;
}

function ShowTransition(gameIntro, index) {
    var currentTextItem = getTextItem(gameIntro, index);
    var nextTextItem = getTextItem(gameIntro, index + 1);
    $("#introText").hide();
    $("#introText").html("");
    $("#introText").removeClass();
    $("#introText").addClass(currentTextItem.Style);
    $("#introText").html(currentTextItem.ContentHtml);
    $("#introText").delay(2000).fadeIn(2000);
    $("#introText").delay(currentTextItem.SecondsToDisplay * 1000).fadeOut(2000).hide();
    if (nextTextItem !== null) {
        setTimeout(function () {
            ShowTransition(gameIntro, index + 1);
        }, currentTextItem.SecondsToDisplay * 1000 + 6000);
    }
    else {
        setTimeout(function () {
            ShowTransition(gameIntro, 0);
        }, currentTextItem.SecondsToDisplay * 1000 + 10000);
    }
}
GET ON WITH IT!

Moving Swiftly On…

None of this stuff so far has added any more functionality than we already had (which wasn’t much to be honest). So now we get into the nitty gritty of game creation and loading and saving. Here is a new controller class I created – the Game Controller:

using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Cybermancer.CelestialAberration.GameEngine.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Linq;
using Cybermancer.CelestialAberration.GameEngine.Enumerations;
using CyberMancer.CelestialAberration.WebApplication.Models;
using Cybermancer.CelestialAberration.GameEngine.Exceptions;

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

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

        public IActionResult New(string currentCharacterId, string errorMessage)
        {
            var characterSelectService = new CharacterSelectService();
            var model = characterSelectService.GetCharacterSelectModel(_hostingEnvironment, currentCharacterId);
            model.ErrorMessage = errorMessage;
            return View(model);
        }

        public IActionResult CustomizePlayer(string characterId, string errorMessage)
        {
            IGameResourceRepository gameResourceRepository = new GameResourceAssemblyRepository();

            var characterLibrary = gameResourceRepository.GetCharacterLibrary("Default");

            var character = characterLibrary.GetCharacter(characterId);

            if (character == null)
            {
                return RedirectToAction("New", "Game", new { characterId = characterId, errorMessage = "Character Selection Failed!" });
            }
            else
            {
                var navigationService = new NavigationService(_hostingEnvironment);

                return View(new CustomisePlayerModel()
                {
                    CharacterId = character.CharacterId,
                    FormalTitle = character.FormalTitle,
                    FirstNames = string.Join(" ", character.FirstNames),
                    LastName = character.LastName,
                    Gender = character.Gender.ToString(),
                    SexualOrientation = character.SexualOrientation.ToString(),
                    Navigation = navigationService.GetNavigationModel("newgame"),
                    ErrorMessage = errorMessage
                });
            }
        }

        [HttpPost]
        public IActionResult Create(CustomisePlayerModel formData)
        {
            IGameDataRepository gameDataRepository = new GameDataRepository(_hostingEnvironment);
            IGameResourceRepository gameResourceRepository = new GameResourceAssemblyRepository();

            try
            {
                var characterLibrary = gameResourceRepository.GetCharacterLibrary("Default");

                var character = characterLibrary.GetCharacter(formData.CharacterId);

                if (character == null)
                {
                    return RedirectToAction("New", "Game", new { characterId = formData.CharacterId, errorMessage = "Invalid character selection." });
                }
                else
                {
                    character.FormalTitle = formData.FormalTitle;
                    character.FirstNames = formData.FirstNames.Split(' ').ToList();
                    character.LastName = formData.LastName;

                    if (Gender.TryParse(formData.Gender, out Gender gender))
                    {
                        character.Gender = gender;
                    }
                    else
                    {
                        return RedirectToAction("CustomizePlayer", "Game", new { characterId = formData.CharacterId, errorMessage = "Invalid gender selected" });
                    }

                    if (SexualOrientation.TryParse(formData.SexualOrientation, out SexualOrientation sexuality))
                    {
                        character.SexualOrientation = sexuality;
                    }
                    else
                    {
                        return RedirectToAction("CustomizePlayer", "Game", new { characterId = formData.CharacterId, errorMessage = "Invalid sexual orientation selected" });
                    }

                    character.Gender = Enum.Parse<Gender>(formData.Gender);
                    character.SexualOrientation = Enum.Parse<SexualOrientation>(formData.SexualOrientation);

                    var gameData = new GameData()
                    {
                        Id = Guid.NewGuid().ToString("N"),
                        Language = "Default",
                        LastModified = DateTime.Now,
                        PlayerCharacter = character
                    };

                    gameDataRepository.StoreGameData(gameData);

                    return RedirectToAction("Start", "Game", new { gameId = gameData.Id });
                }
            }
            catch (GameEngineException e)
            {
                return RedirectToAction("New", "Game", new { characterId = formData.CharacterId, errorMessage = e.ErrorMessage.userMessage });
            }
        }

        public IActionResult Start(string gameId)
        {
            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);

                return View(new GameInfoModel()
                {
                    GameInfo = gameDataRepository.GetGameIndex().GetGameInfo(gameData.Id),
                    Navigation = navigationService.GetNavigationModel("gamestarted")
                });
            }
            catch (GameEngineException e)
            {
                return RedirectToAction("ManageSaves", "Game", new { errorMessage = e.ErrorMessage.userMessage });
            }
        }

        public IActionResult ManageSaves(string errorMessage)
        {
            IGameDataRepository gameDataRepository = new GameDataRepository(_hostingEnvironment);
            var navigationService = new NavigationService(_hostingEnvironment);

            try
            {
                return View(new SavedGamesModel()
                {
                    GameIndex = gameDataRepository.GetGameIndex(),
                    Navigation = navigationService.GetNavigationModel("gameselection"),
                    ErrorMessage = errorMessage
                });
            }
            catch (GameEngineException e)
            {
                return RedirectToAction("New", "Game", new { errorMessage = e.ErrorMessage.userMessage });
            }
        }

        public IActionResult Delete(string gameId)
        {
            IGameDataRepository gameDataRepository = new GameDataRepository(_hostingEnvironment);

            try
            {
                gameDataRepository.DeleteGameData(gameId);

                var gameIndex = gameDataRepository.GetGameIndex();

                if (gameIndex.GameCatalog.Count == 0)
                {
                    return RedirectToAction("Index", "Home");
                }
                else
                {
                    return RedirectToAction("ManageSaves", "Game");
                }
            }
            catch (GameEngineException e)
            {
                return RedirectToAction("ManageSaves", "Game", new { errorMessage = e.ErrorMessage.userMessage });
            }
        }
    }
}

Its pretty straightforward but I will walk through each Action in turn, following the flow of user interaction.

When a user selects the New Game option from the menu they hit the New Action method of the GameController. This builds up a CharacterSelection model that holds the ids of the previous and next characters in a list and the character object for the currently selected character in the list. When it is first called, currentCharacterId is null and so characterSelectService will default to the first character in the list.

using System.Security.Cryptography.X509Certificates;
using Cybermancer.CelestialAberration.GameEngine.Enumerations;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Cybermancer.CelestialAberration.GameEngine.Repositories;
using CyberMancer.CelestialAberration.WebApplication.Models;
using Microsoft.AspNetCore.Hosting;

namespace CyberMancer.CelestialAberration.WebApplication.Services
{
    public class CharacterSelectService
    {
        public CharacterSelectModel GetCharacterSelectModel(IHostingEnvironment hostingEnvironment, string characterToSelect)
        {
            IGameResourceRepository repository = new GameResourceAssemblyRepository();

            var library = repository.GetCharacterLibrary("Default");

            var playerCharacters = library.GetCharacters(Position.Captain);

            var lastCharacter = string.Empty;
            var nextCharacter = string.Empty;

            Character currentCharacter = null;

            for (int currentIndex = 0; currentIndex < playerCharacters.Count; currentIndex++)
            {
                currentCharacter = playerCharacters[currentIndex];

                if (currentCharacter.CharacterId == characterToSelect)
                {
                    if (currentIndex > 0)
                    {
                        lastCharacter = playerCharacters[currentIndex - 1].CharacterId;
                    }

                    if (currentIndex < playerCharacters.Count -1)
                    {
                        nextCharacter = playerCharacters[currentIndex + 1].CharacterId;
                    }

                    break;
                }
            }

            if (currentCharacter.CharacterId != characterToSelect)
            {
                currentCharacter = playerCharacters[0];
                nextCharacter = playerCharacters[1].CharacterId;
            }

            NavigationService navigationService = new NavigationService(hostingEnvironment);

            return new CharacterSelectModel()
            {
                PreviousCharacterId = lastCharacter,
                NextCharacterId = nextCharacter,
                SelectedCharacter =  currentCharacter,
                Navigation = navigationService.GetNavigationModel("newgame")
            };
        }
    }
}
using Cybermancer.CelestialAberration.GameEngine.GameObjects;

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

        public string NextCharacterId { get; set; }

        public Character SelectedCharacter { get; set; }
    }
}

The view renders the currently visible character in the list. The previous and next buttons point back to the same Action method but supplying the id of the previous and next characters respectively. The Select button passes the current character id to the CustomizePlayer action.

@model CyberMancer.CelestialAberration.WebApplication.Models.CharacterSelectModel
@section Navigation
{
    <partial name="Navigation" for="Navigation" />
}
@{
    ViewData["Title"] = "Celestial Aberration - Character Selection";
}
<h2>@ViewData["Title"]</h2>
<span class="text-danger">@Model.ErrorMessage</span>
<p>
    Please select the character you wish to play as. You can customise the name, gender
    and sexual orientation of your characterbefore playing
</p>
<h2>@Model.SelectedCharacter.FullName</h2>
<p>
    Gender : @Model.SelectedCharacter.Gender.ToString()<br />
    Nationality: @Model.SelectedCharacter.Nationality<br />
    Sexual Orientation:@Model.SelectedCharacter.SexualOrientation.ToString()<br />
</p>
<h3>Biography</h3>
<p>@Html.Raw(Model.SelectedCharacter.BiographyText)</p>
<div>
    @if (!string.IsNullOrEmpty(Model.PreviousCharacterId))
    {
        <span>@Html.ActionLink("<- Previous", "New", "Game", new { currentCharacterId = Model.PreviousCharacterId })</span>
    }
      
    <span>@Html.ActionLink("SELECT CHARACTER", "CustomizePlayer", "Game", new { characterId = Model.SelectedCharacter.CharacterId })</span>
      
    @if (!string.IsNullOrEmpty(Model.NextCharacterId))
    {
        <span>@Html.ActionLink("Next ->", "New", "Game", new { currentCharacterId = Model.NextCharacterId })</span>
    }
</div>
Character Selection Page

The CustomizePlayer Action is triggered when the user selects a character from the selection page. This loads the editable parts of the character into a model and then displays a form to allow the user to edit the selected character.

using System.ComponentModel;

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

        [DisplayName("Title")]
        public string FormalTitle { get; set; }

        [DisplayName("First Name(s)")]
        public string FirstNames { get; set; }

        [DisplayName("Last Name")]
        public string LastName { get; set; }

        [DisplayName("Gender")]
        public string Gender { get; set; }

        [DisplayName("Sexual Orientation")]
        public string SexualOrientation { get; set; }
    }
}
@using Cybermancer.CelestialAberration.GameEngine.Enumerations
@model CyberMancer.CelestialAberration.WebApplication.Models.CustomisePlayerModel
@section Navigation
{
    <partial name="Navigation" for="Navigation" />
}
@{
    ViewData["Title"] = "Celestial Aberration - Customize Player";
    var genderValues = Enum.GetValues(typeof(Gender)).Cast<Gender>();

    List<SelectListItem> genderItems = genderValues.Select(value => new SelectListItem()
    {
        Text = value.ToString(),
        Value = value.ToString(),
        Selected = (value.ToString() == Model.Gender)
    }).ToList();

    var sexualityValues = Enum.GetValues(typeof(SexualOrientation)).Cast<SexualOrientation>();

    List<SelectListItem> sexualityItems = sexualityValues.Select(value => new SelectListItem()
    {
        Text = value.ToString(),
        Value = value.ToString(),
        Selected = (value.ToString() == Model.SexualOrientation)
    }).ToList();
}

<h2>CustomizePlayer</h2>
<span class="text-danger">@Model.ErrorMessage</span>
<p>Use the following form to customise your character.</p>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="CustomizePlayer">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <input type="hidden" asp-for="CharacterId" class="form-control" />
            </div>
            <div class="form-group">
                <label asp-for="FormalTitle" class="control-label"></label>
                <input asp-for="FormalTitle" class="form-control" />
                <span asp-validation-for="FormalTitle" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="FirstNames" class="control-label"></label>
                <input asp-for="FirstNames" class="form-control" />
                <span asp-validation-for="FirstNames" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="LastName" class="control-label"></label>
                <input asp-for="LastName" class="form-control" />
                <span asp-validation-for="LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Gender" class="control-label"></label>
                @Html.DropDownList("Gender", genderItems, "Select Gender", new { @class = "form-control" })
                <span asp-validation-for="Gender" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="SexualOrientation" class="control-label"></label>
                @Html.DropDownList("SexualOrientation", sexualityItems, "Select Sexual Orientation", new { @class = "form-control" })
                <span asp-validation-for="SexualOrientation" class="text-danger"></span>
            </div>
            <div class="form-group">
                <br/>
                <input type="submit" value="Start Game" formaction="@Url.Action("Create")" class="btn btn-default"/>
            </div>
        </form>
    </div>
</div>
The Customize Player Page

After editing the details and hitting Start Game, the Create Action is called. This will populate a new gameData object, save it and then load it back in from disk. It then redirects to the Start action. This populates a new GameInfoModel that it passes to a view which simply displays the game that is loaded from the filesystem.

using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class GameInfoModel : BasePageModel
    {
        public GameDataInfo GameInfo { get; set; }
    }
}
@model CyberMancer.CelestialAberration.WebApplication.Models.GameInfoModel
@section Navigation
{
    <partial name="Navigation" for="Navigation" />
}
@{
    ViewData["Title"] = "Celestial Aberration - Game Loaded";
}
<h2>@ViewData["Title"]</h2>
<p>
    The game has been loaded.
</p>
<h2>@Model.GameInfo.PlayerCharacter.FullName</h2>
<p>
    Gender : @Model.GameInfo.PlayerCharacter.Gender.ToString()<br />
    Nationality: @Model.GameInfo.PlayerCharacter.Nationality<br />
    Sexual Orientation: @Model.GameInfo.PlayerCharacter.SexualOrientation.ToString()<br />
</p>
<h3>Biography</h3>
<p>@Html.Raw(Model.GameInfo.PlayerCharacter.BiographyText)</p>
<h3>Game Info</h3>
<p>
    Game Id : @Model.GameInfo.Id<br />
    Language : @Model.GameInfo.Language<br />
    Created : @Model.GameInfo.LastModified.ToLongDateString() At @Model.GameInfo.LastModified.ToShortTimeString()
</p>
Game Loaded Page

Welcome to The Management

Da Management!

Brownie points (and commiserations) to anyone who knows who the two fellas above are. The final part of the current iteration is the loading and deleting of saved games, accessible via the Load Existing Game menu of the application. When the user clicks on this link the ManageSaves action is triggered. The populates a SavedGamesModel that is then passed onto the ManageSaves view.

using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace CyberMancer.CelestialAberration.WebApplication.Models
{
    public class SavedGamesModel : BasePageModel
    {
        public GameIndex GameIndex { get; set; }
    }
}
@model CyberMancer.CelestialAberration.WebApplication.Models.SavedGamesModel
@section Navigation
{
    <partial name="Navigation" for="Navigation" />
}
@{
    ViewData["Title"] = "Celestial Aberration - Load a saved game";
}
<h2>@ViewData["Title"]</h2>
<span class="text-danger">@Model.ErrorMessage</span>
<table class="table">
    <thead>
        <tr>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.GameIndex.GameCatalog)
        {
        <tr>
            <td>
                Player : @item.PlayerCharacter.FullName Language: @item.Language (Created on @item.LastModified.ToLongDateString() at @item.LastModified.ToShortTimeString())
            </td>
            <td>
                @Html.ActionLink("Delete", "Delete", new { gameId = item.Id }, new { onclick = "return confirm('Are sure you want to delete this game?');" })
            </td>
            <td>
                @Html.ActionLink("Load", "Start", new { gameId = item.Id })
            </td>
        </tr>
        }
    </tbody>
</table>

This presents a list of all saved games along with links to delete or load a game.

The Load Existing Game Page

Clicking on delete calls the Delete action. This simply deletes the selected game by the gameId and then redirects back to the ManageSaves action if any saves still exist, or to the index of the application if all saves have been deleted.

Clicking on Load calls the Start Action which we have mentioned earlier. It is the same action / view used after the player has created a new game and loads the current game in from the file system.

And so ends week one. Hopefully I will have progressed a bit more with this in week two (as I said at the start I’m moving to weekly blog entries). Until then…

Smoke me a kipper. I’ll be back for breakfast.


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.