Pull to refresh

Unity: система сохранения для любого проекта

Reading time8 min
Views28K
Игры надо сохранять. Сохраняемых сущностей может быть великое множество. Например, в последних выпусках TES и Fallout игра помнит расположение каждой закатившейся склянки. Необходимо решение, чтобы:

1) Написал один раз и используй в любом проекте для любых сущностей. Ну, насколько возможно.
2) Создал сущность — и она сохраняется сама собою, с минимумом дополнительных усилий.

Решение пришло из стана синглтонов. Не надоело ли вам писать один и тот же синглтон-код? А меж тем есть generic singleton.

Вот как он выглядит для MonoBehaviour
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GenericSingleton<T> : MonoBehaviour {
    static GenericSingleton<T> instance;
    public static GenericSingleton<T> Instance { get { return instance; } }

	void Awake () {
		if (instance && instance != this)
        {
            Destroy(this);
            return;
        }
        instance = this;
	}
}

public class TestSingletoneA : GenericSingleton<TestSingletoneA> {

	// Use this for initialization
	void Start () {
        Debug.Log("A");
	}
}


Т.е. можно сделать Generic класс, статические поля которого будут уникальны для каждого входного типа.

И это как раз наш случай. Потому что поведение сохраняемого объекта полностью идентично, различаются только сохраняемые модели. И тип модели как раз и выступает в качестве входного.

Вот код интерфейса модели. Он примечателен тем, что метод SetValues примет в качестве аргумента только модель такого же (или производного) типа. Не чудо ли?

AbstractModel
/// <summary>
/// Voloshin Game Framework: basic scripts supposed to be reusable
/// </summary>
namespace VGF
{    
    //[System.Serializable]
    public interface AbstractModel<T> where T : AbstractModel<T>, new()
    {
        /// <summary>
        /// Copy fields from target
        /// </summary>
        /// <param name="model">Source model</param>
        void SetValues(T model);            
    }

    public static class AbstratModelMethods
    {
        /// <summary>
        /// Initialize model with source, even if model is null
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="model">Target model, can be null</param>
        /// <param name="source">Source model</param>
        public static void InitializeWith<T>(this T model, T source) where T: AbstractModel<T>, new ()
        {
            //model = new T();
            if (source == null)
                return;
            model.SetValues(source);
        }
    }
}


Для модели также нужен обобщенный контроллер, но с ним связан нижеследующий нюанс, поэтому пока что опустим.

От этих классов — абстрактной модели и обобщенного контроллера можно наследовать всё, что сохраняется и загружается. Написал модель, унаследовал контроллер — и забыл, всё работает. Отлично!

А что делать с сохранением и загрузкой? Ведь нужно сохранять и загружать сразу всё. А писать для каждой новой сущности код для сохранения и загрузки в каком-нибудь SaveLoadManager — утомительно и легкозабываемо.

И тут на помощь приходят статики.

1) Абстрактный класс с protected функциями сохранения и загрузки
2) У него — статичная коллекция All, куда каждый экземпляр класса-потомка добавляется при инициализации
3) И статичные публичные функции сохранения и загрузки, внутри которых перебираются все экземпляры из All и вызываются конкретные методы сохранения и загрузки.

И вот какой код получается в результате.

SaveLoadBehaviour
using System.Collections.Generic;
using UnityEngine;

namespace VGF
{
    /* Why abstract class instead of interface?
     * 1) Incapsulate all save, load, init, loadinit functions inside class, make them protected, mnot public
     * 2) Create static ALL collection and static ALL methods
     * */
     //TODO: create a similar abstract class for non-mono classes. For example, PlayerController needs not to be a MonoBehaviour
    /// <summary>
    /// Abstract class for all MonoBehaiour classes that support save and load
    /// </summary>
    public abstract class SaveLoadBehaviour : CachedBehaviour
    {
        /// <summary>
        /// Collection that stores all SaveLoad classes in purpose of providing auto registration and collective save and load
        /// </summary>
        static List<SaveLoadBehaviour> AllSaveLoadObjects = new List<SaveLoadBehaviour>();

        protected override void Awake()
        {
            base.Awake();
            Add(this);
        }

        static void Add(SaveLoadBehaviour item)
        {
            if (AllSaveLoadObjects.Contains(item))
            {
                Debug.LogError(item + "  element is already in All list");
            }
            else
                AllSaveLoadObjects.Add(item);
        }

        public static void LoadAll()
        {
            foreach (var item in AllSaveLoadObjects)
            {
                if (item == null)
                {
                    Debug.LogError("empty element in All list");
                    continue;
                }
                else
                    item.Load();
            }
        }

        public static void SaveAll()
        {
            Debug.Log(AllSaveLoadObjects.Count);
            foreach (var item in AllSaveLoadObjects)
            {
                if (item == null)
                {
                    Debug.LogError("empty element in All list");
                    continue;
                }
                else
                    item.Save();
            }
        }

        public static void LoadInitAll()
        {
            foreach (var item in AllSaveLoadObjects)
            {
                if (item == null)
                {
                    Debug.LogError("empty element in All list");
                    continue;
                }
                else
                    item.LoadInit();
            }
        }

        protected abstract void Save();

        protected abstract void Load();

        protected abstract void Init();

        protected abstract void LoadInit();
    }
}


GenericModelBehaviour<T>
using UnityEngine;

namespace VGF
{
    /// <summary>
    /// Controller for abstract models, providing save, load, reset model
    /// </summary>
    /// <typeparam name="T">AbstractModel child type</typeparam>
    public class GenericModelBehaviour<T> : SaveLoadBehaviour where T: AbstractModel<T>, new()
    {
        [SerializeField]
        protected T InitModel;
        //[SerializeField]
        protected T CurrentModel, SavedModel;

        protected override void Awake()
        {
            base.Awake();
            //Init();
        }

        void Start()
        {
            Init();
        }

        protected override void Init()
        {
            //Debug.Log(InitModel);
            if (InitModel == null)
                return;
            //Debug.Log(gameObject.name + " : Init current model");
            if (CurrentModel == null)
                CurrentModel = new T();
            CurrentModel.InitializeWith(InitModel);
            //Debug.Log(CurrentModel);
            //Debug.Log("Init saved model");
            SavedModel = new T();
            SavedModel.InitializeWith(InitModel);
        }

        protected override void Load()
        {
            //Debug.Log(gameObject.name + "   saved");
            LoadFrom(SavedModel);
        }

        protected override void LoadInit()
        {
            LoadFrom(InitModel);
        }

        void LoadFrom(T source)
        {
            if (source == null)
                return;
            CurrentModel.SetValues(source);
        }

        protected override void Save()
        {
            //Debug.Log(gameObject.name + "   saved");
            if (CurrentModel == null)
                return;
            if (SavedModel == null)
                SavedModel.InitializeWith(CurrentModel);
            else
                SavedModel.SetValues(CurrentModel);
        }
    }
}


Примеры унаследованных конкретных классов:

AbstractAliveController : GenericModelBehaviour
public abstract class AbstractAliveController : GenericModelBehaviour<AliveModelTransform>, IAlive
    {
        //TODO: create separate unity implementation where put all the [SerializeField] attributes
        [SerializeField]
        bool Immortal;
        static Dictionary<Transform, AbstractAliveController> All = new Dictionary<Transform, AbstractAliveController>();
        public static bool GetAliveControllerForTransform(Transform tr, out AbstractAliveController aliveController)
        {
            return All.TryGetValue(tr, out aliveController);
        }

        DamageableController[] BodyParts;
        
        public bool IsAlive { get { return Immortal || CurrentModel.HealthCurrent > 0; } }
        public bool IsAvailable { get { return IsAlive && myGO.activeSelf; } }
        public virtual Vector3 Position { get { return myTransform.position; } }

        public static event Action<AbstractAliveController> OnDead;
        /// <summary>
        /// Sends the current health of this alive controller
        /// </summary>
        public event Action<int> OnDamaged;

        //TODO: create 2 inits
        protected override void Awake()
        {
            base.Awake();
            All.Add(myTransform, this);            
        }

        protected override void Init()
        {
            InitModel.Position = myTransform.position;
            InitModel.Rotation = myTransform.rotation;
            base.Init();
            
            BodyParts = GetComponentsInChildren<DamageableController>();
            foreach (var bp in BodyParts)
                bp.OnDamageTaken += TakeDamage;
        }

        protected override void Save()
        {
            CurrentModel.Position = myTransform.position;
            CurrentModel.Rotation = myTransform.rotation;
            base.Save();
        }

        protected override void Load()
        {
            base.Load();
            LoadTransform();
        }

        protected override void LoadInit()
        {
            base.LoadInit();
            LoadTransform();
        }

        void LoadTransform()
        {
            myTransform.position = CurrentModel.Position;
            myTransform.rotation = CurrentModel.Rotation;
            myGO.SetActive(true);
        }

        public void Respawn()
        {
            LoadInit();
        }

        public void TakeDamage(int damage)
        {
            if (Immortal)
                return;
            CurrentModel.HealthCurrent -= damage;
            OnDamaged.CallEventIfNotNull(CurrentModel.HealthCurrent);
            if (CurrentModel.HealthCurrent <= 0)
            {
                OnDead.CallEventIfNotNull(this);
                Die();
            }
        }

        public int CurrentHealth
        {
            get { return CurrentModel == null? InitModel.HealthCurrent: CurrentModel.HealthCurrent; }
        }

        protected abstract void Die();

    }


AliveModelTransform : AbstractModel
namespace VGF.Action3d
{
    [System.Serializable]
    public class AliveModelTransform : AliveModelBasic, AbstractModel<AliveModelTransform>
    {
        [HideInInspector]
        public Vector3 Position;
        [HideInInspector]
        public Quaternion Rotation;

        public void SetValues(AliveModelTransform model)
        {
            Position = model.Position;
            Rotation = model.Rotation;
            base.SetValues(model);
        }
    }
}


Недостатки решения и способы их исправления.

1) Сохраняется (перезаписывается) всё. Даже то, что не было изменено.

Возможное решение: проверять перед сохранением равенство полей у исходной и текущей моделей и сохранять только при необходимости.

2) Загрузка из файла. Из json, например. Вот есть список моделей. Как загрузчику узнать, какой класс надо создать для этого json-текста?

Возможное решение: сделать словарь <System.Type, string> где регистрировать типы хардкодом. При загрузке из json берется строковой идентификатор типа и инстанцируется объект нужного класса. При сохранении объект проверяет, есть ли в словаре ключ его типа, и выдает сообщение/ошибку/исключение. Это позволит стороннему программисту не забыть добавить новый тип в словарь.

Посмотреть мой код с этим и другими хорошими решениями можно здесь (проекты в начальной стадии):

FPSProject
Невероятные космические похождения изворотливых котосминогов

Замечания, улучшения, советы — приветствуются.
Предложения помощи и совместного творчества приветствуются.
Предложения о работе крайне приветствуются.


UPD:
Вижу, возникают вопросы а ля «Каков профит от твоего решения? Все равно же делать модели, делать сериализацию.»
Отвечаю:
Вы пришли на чекпоинт или нажали кнопку сохранить. Кнопка или чекпоинт сообщили классу-менеджеру, что нужно сохранить состояние игры. Что делает менеджер?

Плохой вариант 1:
void Save()
{
    Entity1.Save;
    Entity2.Save;
    Entity3.Save;
    ...
    EntityInfinity.Save;
}

Плохой вариант 2: Каждый SaveLoadBehaviour подписывается на событие OnSave менеджера. Или регистрирует себя в каком-то «контейнере».
Плохо, потому что SaveLoadBehaviour должен знать о существовании менеджера/контейнера. Я же пытался сделать так, чтобы классы были максимально автономны, а все знания об их связях хранились в самом менеджере.

Плохой вариант 3: менеджер при инициализации ищет все сохраняемые компоненты.
1) Функция поиска может отличаться между платформами. GameObject.FindObjectsOfType() применима только для MonoBehaviour, а что если мы делаем shared-логику? Реализация должна быть максимально гибкой и кроссплатформенной.
2) Если мы решим переписать менеджер с нуля (для другой игры, например), то надо обязательно не забыть вставить функцию поиска.

Мой хороший вариант:
class GameManager
{
    void Save()
    {
        AbstractSaveLoadBehaviour.SaveAll();
    }
}


Еще мне задали вопрос, что делать, если мы хотим положить на один геймобжект несколько saveloadbehaviour? Как они при загрузке соберутся в один геймобжект?

Вот решение, которое пришло мне на ум:
  1. В каждый SaveloadBehaviour в функции сохранения и загрузки добавить вызов события.
    void Save()
    {
        if (OnSaveSendToMainBehaviour == null)
        {
            //сохраняем в файл
        }
        else
        OnSaveSendToMainBehaviour(savedModel)
    }
    
  2. ГЛАВНЫЙ контроллер сущности, который при инициализации ищет все компоненты SaveLoadBehaviour и подписывается на их события.
  3. Если он есть, то он агрегирует события от всех контроллеров сохранения, собирает их модели и сохраняет их в файл сам, единолично.
  4. Чтобы проверить, что все контроллеры уже всё отправили, можно сделать счетчик.
    voidOnSaveModelFromDependentController(model partModel)
    {
        currentSaveCount++;        
        model.AddPArtModel(partModel);    
        if (currentSaveCount == TotalSaveCount)
            Save(model);
    }
  5. И даже добавление такого убер-контроллера можно автоматизировать. Каждый saveloadbehaviour на Awake или Start ищет, есть ли другие. Если есть, то ищет убер-контроллер и по необходимости добавляет.
    А убер-контроллер на Awake или Start подписывается на всех.
    Двойной подписки не произойдет, т.к. убер-контроллер будет добавлен лишь единожды, и его Awake/Start тоже лишь единожды будет вызван.
Tags:
Hubs:
Total votes 14: ↑13 and ↓1+12
Comments28

Articles