Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!

Dzisiejszy odcinek: Jak stworzyć zapis stanu gry

Uwaga! Jest to poradnik typu QuickTip. Zatem skupia się on na osiągnięciu założonego celu. Zatem zakładamy że użytkownik zna na tyle program Unity3d, aby samodzielnie wykonać najprostsze czynności, jak np. dodanie modelu kostki do sceny czy dodanie modelowi jakiegoś komponentu. Jeżeli brakuje Ci tej podstawowej wiedzy, zapraszam do tutoriala:
Unity Tutorial – Podstawy

Teoria

Zapis stanu gry, czy np. tabel wyników, jest kluczowy dla gier. Wynika to ze względów oczywistych. Kto przejdzie grę w jednym ciągu bez przerw? Albo co nam ze starań uzyskania najlepszego wyniku, jak nie możemy wytknąć znajomym, że są od nas gorsi?

Zapis możemy zrobić na dwa sposoby. Zapis prosty z wykorzystaniem PlayerPrefs – dobry dla prostych gier, oraz  bardziej zaawansowany, dla większych projektów – serializację. Ktoś może zaproponować zapis danych w pliku XML czy tekstowym. Da się, ale to bez sensu. Każdy średnio ogarnięty użytkownik komputera, może wtedy otworzyć taki plik w notatniku i nieźle namieszać.

Pozostała jeszcze kwestia losowo generowanych elementów. Ale to jest wbrew pozorom sprawa prosta i tym zajmiemy się na końcu.

Zapis prosty – PlayerPrefs

Tutaj sprawa jest prosta. System służy do zapisywania pojedynczych zmiennych. Więc jeśli tworzysz rozbudowaną grę RPG, to pomiń ten dział. Jak chcesz zapamiętać na którym poziomie Twojej platformówki jest gracz, to Ci w zupełności wystarczy. Nie przedłużam bo sprawa jest prosta. Możemy przechować za pomocą tej metody 3 typy zmiennych: Int, Float i String.

// C#
PlayerPrefs.SetFloat("Player Score", 10.0F);
PlayerPrefs.SetInt("Player Score", 10);
PlayerPrefs.SetString("Player Name", "Foobar");
//JavaScript
PlayerPrefs.SetFloat("Player Score", 10.0);
PlayerPrefs.SetInt("Player Score", 10);
PlayerPrefs.SetString("Player Name", "Foobar");

Jak widać dla obu języków, zapis jest taki sam. Składnia jest bardzo prosta. Podajemy najpierw klucz pod którym zapiszemy wartość, a później samą wartość. Coś w rodzaju tablicy asocjacyjnej. Funkcja nie przyjmie tablicy  jako wartości! Ale, żeby zapisać wprowadzone zmienne, trzeba wykonać jeszcze funkcję save:

// C#
PlayerPrefs.Save();
// JavaScript
PlayerPrefs.Save();

Jak odebrać dane? Jak łatwo się domyślić, zmienimy w pierwotnych funkcjach set na get:

// C#
PlayerPrefs.GetFloat("Player Score");
PlayerPrefs.GetInt("Player Score");
PlayerPrefs.GetString("Player Name");
//JavaScript
PlayerPrefs.GetFloat("Player Score");
PlayerPrefs.GetInt("Player Score");
PlayerPrefs.GetString("Player Name");

Może nas najść potrzeba uprzedniego sprawdzenia, czy dany klucz posiadamy zapisany, z pomocą przychodzi nam wtedy funkcja HasKey:

// C#
PlayerPrefs.HasKey("Player Score");
// JavaScript
PlayerPrefs.HasKey("Player Score");

Po podaniu klucza, zwraca nam true lub false, w zależności od tego, czy zapisaliśmy zmienną o danym kluczu, czy nie. W przypadku chęci usunięcia klucza, lub wszystkich danych możemy się posłużyć jedną z dwóch funkcji:

// C#
PlayerPrefs.DeleteKey("Player Score");
PlayerPrefs.DeleteAll();
// JavaScript
PlayerPrefs.DeleteKey("Player Score");
PlayerPrefs.DeleteAll();

Pierwsza funkcja usuwa nam jeden klucz i jego wartość. (Usuwa klucz! Tzn. funkcja HasKey zwróci nam teraz wartość false!). Funkcja DeleteAll usunie wszystkie klucze i wartości (funkcja HasKey, również zwróci false). Dla formalności: Różnicą jest to że DeleteKey usuwa wskazany klucz, a DeleteAll usuwa wszystkie.

Teraz aby zapamiętać np. na której planszy gracz skończył, wystarczy wykorzystać SetInt oraz GetInt to pobierania i zapisywania ostatnio ukończonej planszy.

Jednak jak widać, zapisanie np. ekwipunku gracza, byłoby straszliwą mordęgą. Dlatego z pomocą przychodzi serializacja!

Zapis zaawansowany – Serializacja

Tutaj sprawa jest teoretycznie trudniejsza. Jednak tak naprawdę, sprowadza się to do utworzenia klasy, która będzie przechowywać dane.

Pierwszym krokiem jest utworzenie klasy serializacji:

[Serializable ()]
public class SaveData : ISerializable {
    public bool foundGem1 = false;
    public float score = 42;
    public int levelReached = 3;

    public SaveData () {
    }
}

Pierwsze co rzuca się w oczy to słowo kluczowe Serializable. Druga różnica między zwykłą klasą, a serializacyjną, to fakt, że ta klasa dziedziczy nie po MonoBechaviour, a po ISerializable. Reszta kodu jest nam znana, bo to zwykłe deklaracje zmiennych oraz konstruktora. Teraz musimy określić w kodzie jak dane mają być zapisywane.

public SaveData (SerializationInfo info, StreamingContext ctxt)
{
	foundGem1 = (bool)info.GetValue("foundGem1", typeof(bool));
	score = (float)info.GetValue("score", typeof(float));
	levelReached = (int)info.GetValue("levelReached", typeof(int));
}

Tutaj, nasze dane są w zmiennej Info. My jako programiści, musimy zadbać o to, żeby każda zmienna (którą zadeklarowałeś wcześniej), dostała swoją wartość ze zmiennej info. Warto zrzutować dane na wymagany typ, dla pewności, że wszystko dobrze się zapisze. Oczywiście ten kod ląduje wewnątrz wcześniej utworzonej klasy.

public void GetObjectData (SerializationInfo info, StreamingContext ctxt)
{
        info.AddValue("foundGem1", (foundGem1));
	info.AddValue("score", score);
	info.AddValue("levelReached", levelReached);
}

Kolejna funkcja wewnątrz klasy. Tym razem działanie odwrotne. Czyli wartości ze zmiennych przypisujemy do zmiennej info, która pozwoli przekazać je do gry. Kod również dodajemy do utworzonej klasy.

I tym sposobem otrzymaliśmy naszą klasę kontener do zapisywania danych:

// Kompletna klasa kontenera SaveData
using UnityEngine;

using System.Text;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

using System;
using System.Runtime.Serialization;
using System.Reflection;

[Serializable ()]
public class SaveData : ISerializable {
    public bool foundGem1 = false;
    public float score = 42;
    public int levelReached = 3;

    public SaveData () {
    }

    public SaveData (SerializationInfo info, StreamingContext ctxt)
    {
        foundGem1 = (bool)info.GetValue("foundGem1", typeof(bool));
        score = (float)info.GetValue("score", typeof(float));
        levelReached = (int)info.GetValue("levelReached", typeof(int));
    }

    public void GetObjectData (SerializationInfo info, StreamingContext ctxt)
    {
        info.AddValue("foundGem1", (foundGem1));
        info.AddValue("score", score);
        info.AddValue("levelReached", levelReached);
    }
}

Teraz, potrzebujemy klasy, którą przypiszemy jako asset w grze i umożliwimy jej, skorzystanie z naszego kontenera do zapisu stanu gry.

Pierwszy krok, to oczywiście deklaracja klasy:

public class SaveLoad {

  public static string currentFilePath = "SaveData.cjc";
}

Tym razem klasa nie wymaga dodatkowego dziedziczenia. Jedyną jej zmienną jest ścieżka, do pliku zapisu gry.

public static void Save ()
{
    Save (currentFilePath);
}

public static void Save (string filePath)
{
    SaveData data = new SaveData ();

    Stream stream = File.Open(filePath, FileMode.Create);
    BinaryFormatter bformatter = new BinaryFormatter();
    bformatter.Binder = new VersionDeserializationBinder(); 
    bformatter.Serialize(stream, data);
    stream.Close();
}

Powyższy kod, to oczywiście zapis danych do pliku. Może dziwić fakt, posiada dwóch funkcji save. Jednak pierwsza, to przeładowana funkcja domyślna, która odwołuje się od razu, do drugiej naszej funkcji, wykonując kod w niej. Plus takiego rozwiązania, to fakt że teraz wywołanie zapisu gry, odbywa się za pomocą kodu: SaveLoad.Save(). Nie musimy podawać nazwy pliku zapisu.

Kod wewnątrz drugiej funkcji, działa następująco: Tworzymy sobie instancję klasy naszego kontenera. Otwieramy strumień danych do pliku zapisu. Później dzięki obiektowi typu BinaryFormatter, możemy dane zapisać w pliku.

public static void Load ()  {
    Load(currentFilePath);  
}

public static void Load (string filePath) 
{
    SaveData data = new SaveData ();
    Stream stream = File.Open(filePath, FileMode.Open);
    BinaryFormatter bformatter = new BinaryFormatter();
    bformatter.Binder = new VersionDeserializationBinder(); 
    data = (SaveData)bformatter.Deserialize(stream);
    stream.Close();
}

Zapisaliśmy, to chcemy odczytać. Układ funkcji wygląda tak samo. Nasza funkcja, ponownie wykrzystuje strumienie danych oraz naszą klasę kontener i BinaryFormatter. Teraz nasze dane znajdują się w zmiennej data.

public sealed class VersionDeserializationBinder : SerializationBinder 
{ 
    public override Type BindToType( string assemblyName, string typeName )
    { 
        if ( !string.IsNullOrEmpty( assemblyName ) && !string.IsNullOrEmpty( typeName ) ) 
        { 
            Type typeToDeserialize = null; 
            assemblyName = Assembly.GetExecutingAssembly().FullName; 
            typeToDeserialize = Type.GetType( String.Format( "{0}, {1}", typeName, assemblyName ) ); 
            return typeToDeserialize; 
        } 
        return null; 
    } 
}

Ostatnim kawałkiem kodu jest trzecia klasa. W jej budowę nie będę się zagłębiał. Klasa, jest wymaga do tego, aby zagwarantować nam stałą nazwę serializacji. Normalnie Unity3d przy każdej kompilacji, generuje nową.

Cały kod powinien znaleźć się w jednym pliku, dlatego dla ułatwienia: SaveLoad

Jak widać jest to bardziej skomplikowana kwestia i wymaga więcej kodu. Jednak w ogólnym rozrachunku, daje większą swobodę i działa lepiej.

Kwestia losowych elementów

Jeżeli część lub cały świat jest u nas generowany losowo, to wiadome jest, że przy każdym uruchomieniu i generowaniu świata, będzie on inny. A nie chcemy chyba, przy każdym wczytaniu gry, ujrzeć czegoś nowego i zamiast w bezpiecznym mieście pojawić się np. w bagnie? Głupim pomysłem będzie zapamiętanie lokalizacji każdego elementu gry. Co nam zostaje?

Jeżeli chodzi o elementy losowe jak np. loot (to co zgubią przeciwnicy), to nie ma się co martwić. To że raz znalazłem 2 monety i wytrych, a za drugim razem 3 monety i marchewkę, nie jest złe. Gorzej, jak postać mająca ważny quest była w domu, a po wczytaniu gry, znajduje się w innym mieście.

Jak to rozwiązać? Generując elementy losowe, komputer tak naprawdę generuje liczby pseudolosowe. Opiera się np. na bieżącej dacie systemowej itp. Jest to tak zwane ziarno. Teraz wystarczy zapamiętać ziarno, na podstawie którego został wygenerowany świat i w czasie wczytywania gry, użyć tego samego ziarna. Świat będzie wygenerowany od nowa, ale wszystkie losowe elementy, otrzymają tą samą wartość co przy pierwszym losowaniu.

Podoba Ci się? Udostępnij!