SaveStateWzór.8 Używając wzorca projektowego Command, zapisywaliśmy akcje, które wykonywaliśmy w systemie i dzięki temu mogliśmy później te akcje cofać. 

Istnieje wzorzec projektowy, który też spełnia tę funkcję. Wzorzec Memento przechowuje stan systemu w dedykowanym obiekcie tylko do odczytu.

Ten token będzie użyty do przywrócenia systemu

Przykład z grą komputerową

Klasa Memento będzie zapisywała stan naszej gry. Przechowuje ona liczbę żyć, numer poziomu, jak i obecne ulepszenie, które gracz ma. 

public class Memento
{
    private int _lives;
    private int _level;
    private PowerUps _powerUps;

    public Memento(int level, int lives,
        PowerUps powerUps)
    {
        _lives = lives;
        _level = level;
        _powerUps = powerUps;
    }

    public int Lives { get { return _lives; } }
    public int Level { get { return _level; } }
    public PowerUps PowerUps { get { return _powerUps; } }
}

Sam obiekt Memento powinien być niezmienna ("immutable") . W końcu nie chcemy przywracać stanu gry, który nigdy nie istniał. To tworzyć uszkodzony świat gry.

broken mario.PNG

Nasza gra ma 3 typy ulepszeń. Jako gracz być może będzie chciał przywracać stan gry, do etapu gdzie miałeś więcej żyć lub lepsze ulepszenie.

public enum PowerUps
{
    None = 0,
    RainbowCoat = 1,
    FireBalls = 2,
    SpeedBoots = 3
}

Oto kompletna klasa gry. Mam w niej znowu liczbę żyć, obecny poziom oraz ulepszenia. 

Dużo się tutaj dzieje więc zobaczmy z bliska, co tutaj się dzieje.

public class GameState
{
    private int _lives;
    private int _level;
    private PowerUps _powerUps;

    public GameState(int level, int lives,
        PowerUps powerUps)
    {
        _lives = lives;
        _level = level;
        _powerUps = powerUps;
    }

    public Memento GoToLevelAndSave(
        int lives, PowerUps powerUps)
    {
        _lives = lives;
        _level = _level++;
        _powerUps = powerUps;
    
        return new Memento
            (_level, lives, powerUps);
    }

    public void Restore(Memento m)
    {
        _lives = m.Lives;
        _level = m.Level;
        _powerUps = m.PowerUps;
    }
}

Metoda GotoLevelAndSave symuluje przejście do następnego etapu gry. Zwraca ona obiekt zapisu Memento. 

public Memento GoToLevelAndSave(
    int lives, PowerUps powerUps)
{
    _lives = lives;
    _level = _level++;
    _powerUps = powerUps;

    return new Memento
        (_level, lives, powerUps);
}

Metoda Restore będzie przywracać stan gry na podstawie obiektu Memento.

public void Restore(Memento m)
{
    _lives = m.Lives;
    _level = m.Level;
    _powerUps = m.PowerUps;
}

To wszystko więc testujemy

var gamestate = new GameState(1, 5, PowerUps.None);

var savestate1 =
    gamestate.GoToLevelAndSave(3,
    PowerUps.RainbowCoat);

var savestate2 = gamestate.GoToLevelAndSave(1,
    PowerUps.None);

gamestate.Restore(savestate1);

Oto prosta implementacja tego wzorca. Chociaż przydałoby się jeszcze otrzymać obiekt Memento na starcie gry. Nie jest to możliwe, ponieważ konstruktor nie zwraca przecież dodatkowych obiektów.

Cofanie się do określonego poziomu gry jak przywracanie

Co, jeśli chcesz mieć możliwość cofanie się do każdego stanu gry, jaki udało Ci się zrobić po każdym skończonym poziomie gry. Podobną funkcjonalność zrobiliśmy, robiąc wzorzec Command. Jak to będzie wyglądało z rozwiązaniem Memento:

Najpierw do klasy gry trzeba dodać listę zapisanych stanów gry oraz zmienną określająca obecny stan gry, w którym gra działa.

Chcemy dać możliwość cofania się, jak i powrotu do najnowszego zapisu, jeśli nie jesteśmy zadowolenie ze swojego poprzedniego stanu gry. Czyli będziemy skakać po stanach gry w dwie strony. Do tyłu i do przodu.

public class GameState
{
    private int _lives;
    private int _level;
    private PowerUps _powerUps;
    
    private List<Memento> savestates = new List<Memento>();
    private int currentSaveState = 0;

Pora też zmodyfikować metodę GoToLevelAndSave. Będzie ona dodawać zapis gry do listy i zwiększać wskaźnik obecnego zapisu gry. 

public Memento GoToLevelAndSave(
    int lives, PowerUps powerUps)
{
    _lives = lives;
    _level = _level + 1;
    _powerUps = powerUps;
    ++currentSaveState;

    //deleting saves from next levels
    if (currentSaveState + 1 < savestates.Count())
    {
        savestates.RemoveRange(currentSaveState,
            (savestates.Count() - currentSaveState);
        currentSaveState = savestates.Count() - 1;
    }

    var save = new Memento
        (_level, _lives, _powerUps);
    savestates.Add(save);
    return save;
}

Dodatkowo dodałem funkcjonalność kasowania zapisanych stanów gry. Stanie się tak, jeśli w grze się cofnęliśmy i przechodzimy do następnych poziomów z tego stanu. W takim razie trzeba zrobić porządek na liście i pozbyć się niekompatybilnych stanów gry z listy.

Metoda RepeatPreviosLevel pozwoli nam się cofnąć do poprzedniego poziomu stanu gry.

//UNDO
public Memento RepeatPreviosLevel()
{
    if (currentSaveState > 0)
    {
        var m = savestates[--currentSaveState];
        _lives = m.Lives;
        _level = m.Level;
        _powerUps = m.PowerUps;
        return m;
    }

    return null;
}

Tutaj przydałby się wzorzec NullObject, który by zwrócił obiekt początkowy gry, gdy stanów nie mamy. Obecnie leci tutaj Null.

Dodajemy też metodę powrotu do zapisanego poziomu. Gdy nie mam następnego poziomu, to zwracamy null. 

//REDO
public Memento GoBackToLevel()
{
    if (currentSaveState + 1 < savestates.Count)
    {
        var m = savestates[++currentSaveState];
        _lives = m.Lives;
        _level = m.Level;
        _powerUps = m.PowerUps;
        return m;
    }
    return null;
}

Uzupełniłem też metodę ToString(), aby testowanie tej klasy było łatwiejsze.

public override string ToString()
{
    return $"Lives : {_lives}, " +
        $"Level : {_level}," +
        $" PowerUp : {_powerUps}";
}

Oto kompletna klasa gry:

public class GameState
{
    private int _lives;
    private int _level;
    private PowerUps _powerUps;

    private List<Memento> savestates = new List<Memento>();
    private int currentSaveState = 0;

    public GameState(int level, int lives,
        PowerUps powerUps)
    {
        _lives = lives;
        _level = level;
        _powerUps = powerUps;
    }

    public Memento GoToLevelAndSave(
        int lives, PowerUps powerUps)
    {
        _lives = lives;
        _level = _level + 1;
        _powerUps = powerUps;

        ++currentSaveState;
        var save = new Memento
            (_level, _lives, _powerUps);
        savestates.Add(save);
        return save;
    }

    public void Restore(Memento m)
    {
        if (m != null)
        {
            _lives = m.Lives;
            _level = m.Level;
            _powerUps = m.PowerUps;

            currentSaveState =
                savestates.Count - 1;
        }
    }

    //UNDO
    public Memento RepeatPreviosLevel()
    {
        if (currentSaveState > 0)
        {
            var m = savestates[--currentSaveState];
            _lives = m.Lives;
            _level = m.Level;
            _powerUps = m.PowerUps;
            return m;
        }

        return null;
    }

    //REDO
    public Memento GoBackToLevel()
    {
        if (currentSaveState + 1 < savestates.Count)
        {
            var m = savestates[++currentSaveState];
            _lives = m.Lives;
            _level = m.Level;
            _powerUps = m.PowerUps;
            return m;
        }
        return null;
    }

    public override string ToString()
    {
        return $"Lives : {_lives}, " +
            $"Level : {_level}," +
            $" PowerUp : {_powerUps}";
    }
}

Przetestujmy działanie naszej klasy. Przechodzimy na początku do poziomu 5 z jednym życiem i bez żadnych ulepszeń.

Cofamy się do poziomu obecnego, a potem do 4,3,2. Potem stwierdzamy jednak, że zapis gry z poziomu 3 był lepszy i do niego wracamy

var gamestate = new GameState(1, 5, PowerUps.None);

gamestate.GoToLevelAndSave(5,
    PowerUps.RainbowCoat);

gamestate.GoToLevelAndSave(4,
    PowerUps.FireBalls);

gamestate.GoToLevelAndSave(2,
    PowerUps.SpeedBoots);

gamestate.GoToLevelAndSave(1,
    PowerUps.None);

Console.WriteLine(gamestate);

Console.WriteLine("Let's say you play this game and you want to restart to level 5");
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
Console.WriteLine("");

Console.WriteLine("Let's go back to the saved state at level 4");
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
Console.WriteLine("");

Console.WriteLine("Let's go back to the saved state at level 3");
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
Console.WriteLine("");

Console.WriteLine("Let's go back to the saved state at level 2");
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
Console.WriteLine("");

Console.WriteLine("Neee Level 3 Savestate was ok");
gamestate.GoBackToLevel();
Console.WriteLine(gamestate);

Console.ReadLine();

Oto wyniki działa w naszej konsoli.

memento.PNG

Dodatkowo sprawdzam, czy można cofnąć się do określonego stanu gry i zacząć tworzyć nowe stany od tamtego momentu. Bez czyszczenia listy nie byłoby to możliwe.

var gamestate = new GameState(1, 5, PowerUps.None);

gamestate.GoToLevelAndSave(5, PowerUps.RainbowCoat);
Console.WriteLine(gamestate);
gamestate.GoToLevelAndSave(4, PowerUps.FireBalls);
Console.WriteLine(gamestate);
gamestate.GoToLevelAndSave(2, PowerUps.SpeedBoots);
Console.WriteLine(gamestate);
gamestate.GoToLevelAndSave(1, PowerUps.None);
Console.WriteLine(gamestate);

Console.WriteLine("");
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
gamestate.RepeatPreviosLevel();
Console.WriteLine(gamestate);
Console.WriteLine("");

gamestate.GoToLevelAndSave(6, PowerUps.RainbowCoat);
Console.WriteLine(gamestate);
gamestate.GoToLevelAndSave(5, PowerUps.RainbowCoat);
Console.WriteLine(gamestate);
gamestate.GoToLevelAndSave(4, PowerUps.RainbowCoat);

Console.WriteLine(gamestate);
Console.ReadLine();

Oto rezultat takiego kodu:

memento 2.png

Podsumowanie :

Wzorzec Memento polega na obsłudze tokenów, które przywracają system do określonego stanu. Token zawiera w sobie wszystkie informacje potrzebne do przywrócenia. W tym wypadku były to punkty życia, poziom oraz ulepszacze. 

Jak w przeglądarce internetowej mając ten wzorzec, będziesz mógł iść do tyłu i do przodu po swojej historii stanów aplikacji.

Gdyby miał coś ulepszać w tych przykładach, to bym się tylko zastanowił nad możliwością stworzenia domyślnego obiektu stanu aplikacji. Taki obiekt dobrze byłoby zwracać, gdy dojdziemy do początku naszej historii stanów aplikacji.

public class NullMemento : Memento
{
    public NullMemento() :
        base(1,5,PowerUps.None)
    {

    }
}

Taki obiekt dobrze byłoby zwracać, gdy dojdziemy do początku naszej historii stanów aplikacji lub gdy tej historii jeszcze nie mamy.

public Memento RepeatPreviosLevel()
{
    if (currentSaveState > 0)
    {
        var m = savestates[--currentSaveState];
        _lives = m.Lives;
        _level = m.Level;
        _powerUps = m.PowerUps;
        return m;
    }

    return new NullMemento();
}

//REDO
public Memento GoBackToLevel()
{
    if (currentSaveState + 1 < savestates.Count)
    {
        var m = savestates[++currentSaveState];
        _lives = m.Lives;
        _level = m.Level;
        _powerUps = m.PowerUps;
        return m;
    }
    return new NullMemento();
}

Miłego programowania :)