Игры надо сохранять. Сохраняемых сущностей может быть великое множество. Например, в последних выпусках TES и Fallout игра помнит расположение каждой закатившейся склянки. Необходимо решение, чтобы:
1) Написал один раз и используй в любом проекте для любых сущностей. Ну, насколько возможно.
2) Создал сущность — и она сохраняется сама собою, с минимумом дополнительных усилий.
Решение пришло из стана синглтонов. Не надоело ли вам писать один и тот же синглтон-код? А меж тем есть generic singleton.
Т.е. можно сделать Generic класс, статические поля которого будут уникальны для каждого входного типа.
И это как раз наш случай. Потому что поведение сохраняемого объекта полностью идентично, различаются только сохраняемые модели. И тип модели как раз и выступает в качестве входного.
Вот код интерфейса модели. Он примечателен тем, что метод SetValues примет в качестве аргумента только модель такого же (или производного) типа. Не чудо ли?
Для модели также нужен обобщенный контроллер, но с ним связан нижеследующий нюанс, поэтому пока что опустим.
От этих классов — абстрактной модели и обобщенного контроллера можно наследовать всё, что сохраняется и загружается. Написал модель, унаследовал контроллер — и забыл, всё работает. Отлично!
А что делать с сохранением и загрузкой? Ведь нужно сохранять и загружать сразу всё. А писать для каждой новой сущности код для сохранения и загрузки в каком-нибудь SaveLoadManager — утомительно и легкозабываемо.
И тут на помощь приходят статики.
1) Абстрактный класс с protected функциями сохранения и загрузки
2) У него — статичная коллекция All, куда каждый экземпляр класса-потомка добавляется при инициализации
3) И статичные публичные функции сохранения и загрузки, внутри которых перебираются все экземпляры из All и вызываются конкретные методы сохранения и загрузки.
И вот какой код получается в результате.
Примеры унаследованных конкретных классов:
Недостатки решения и способы их исправления.
1) Сохраняется (перезаписывается) всё. Даже то, что не было изменено.
Возможное решение: проверять перед сохранением равенство полей у исходной и текущей моделей и сохранять только при необходимости.
2) Загрузка из файла. Из json, например. Вот есть список моделей. Как загрузчику узнать, какой класс надо создать для этого json-текста?
Возможное решение: сделать словарь <System.Type, string> где регистрировать типы хардкодом. При загрузке из json берется строковой идентификатор типа и инстанцируется объект нужного класса. При сохранении объект проверяет, есть ли в словаре ключ его типа, и выдает сообщение/ошибку/исключение. Это позволит стороннему программисту не забыть добавить новый тип в словарь.
Посмотреть мой код с этим и другими хорошими решениями можно здесь (проекты в начальной стадии):
→ FPSProject
→ Невероятные космические похождения изворотливых котосминогов
Замечания, улучшения, советы — приветствуются.
Предложения помощи и совместного творчества приветствуются.
Предложения о работе крайне приветствуются.
UPD:
Вижу, возникают вопросы а ля «Каков профит от твоего решения? Все равно же делать модели, делать сериализацию.»
Отвечаю:
Вы пришли на чекпоинт или нажали кнопку сохранить. Кнопка или чекпоинт сообщили классу-менеджеру, что нужно сохранить состояние игры. Что делает менеджер?
Плохой вариант 1:
Плохой вариант 2: Каждый SaveLoadBehaviour подписывается на событие OnSave менеджера. Или регистрирует себя в каком-то «контейнере».
Плохо, потому что SaveLoadBehaviour должен знать о существовании менеджера/контейнера. Я же пытался сделать так, чтобы классы были максимально автономны, а все знания об их связях хранились в самом менеджере.
Плохой вариант 3: менеджер при инициализации ищет все сохраняемые компоненты.
1) Функция поиска может отличаться между платформами. GameObject.FindObjectsOfType() применима только для MonoBehaviour, а что если мы делаем shared-логику? Реализация должна быть максимально гибкой и кроссплатформенной.
2) Если мы решим переписать менеджер с нуля (для другой игры, например), то надо обязательно не забыть вставить функцию поиска.
Мой хороший вариант:
Еще мне задали вопрос, что делать, если мы хотим положить на один геймобжект несколько saveloadbehaviour? Как они при загрузке соберутся в один геймобжект?
Вот решение, которое пришло мне на ум:
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? Как они при загрузке соберутся в один геймобжект?
Вот решение, которое пришло мне на ум:
- В каждый SaveloadBehaviour в функции сохранения и загрузки добавить вызов события.
void Save() { if (OnSaveSendToMainBehaviour == null) { //сохраняем в файл } else OnSaveSendToMainBehaviour(savedModel) } - ГЛАВНЫЙ контроллер сущности, который при инициализации ищет все компоненты SaveLoadBehaviour и подписывается на их события.
- Если он есть, то он агрегирует события от всех контроллеров сохранения, собирает их модели и сохраняет их в файл сам, единолично.
- Чтобы проверить, что все контроллеры уже всё отправили, можно сделать счетчик.
voidOnSaveModelFromDependentController(model partModel) { currentSaveCount++; model.AddPArtModel(partModel); if (currentSaveCount == TotalSaveCount) Save(model); } - И даже добавление такого убер-контроллера можно автоматизировать. Каждый saveloadbehaviour на Awake или Start ищет, есть ли другие. Если есть, то ищет убер-контроллер и по необходимости добавляет.
А убер-контроллер на Awake или Start подписывается на всех.
Двойной подписки не произойдет, т.к. убер-контроллер будет добавлен лишь единожды, и его Awake/Start тоже лишь единожды будет вызван.
