Просто о внутренних и внешних настройках для приложения в Unity3D

    Введение


    Здравствуйте уважаемые читатели, в сегодняшней статье хотелось бы поговорить о настройках и конфигурировании игровых приложений, создаваемых в среде Unity3d.

    По традиции начну с предыстории. За время работы в игровой индустрии я разрабатывал различные проекты, с разной сложностью и побывал и в лагере гейм-дизайна, и в лагере программистов (где нахожусь и по сей день). Ни для кого не секрет, что любое приложение требует большого числа различных конфигурационных данных и настроек. В классическом, относительно Unity3d виде, такие настройки выносятся в видимую часть инспектора, где вводятся какие-то цифры и т.п. Я думаю, что говорить об удобстве такого подхода не стоит, это даже если исключить, факт, того, что на время настройки, сцена, в которой находится MonoBehaviour класс блокируется для изменений другими разработчиками. Поэтому после череды разного рода мытарств в прошлом, я решил, написать что-то простое и эффективное, что облегчит всем жизнь и упростит работу с такими данными, чем и хочу поделиться с вами.

    Примечание: весь описанный ниже код применим к версии Unity 2018.3+ и использует компилятор Roslyn (версия языка C# 7+).

    Внутренние настройки


    Для начала рассмотрим внутренние настройки проекта, к которым относятся различные константы, ссылки, идентификаторы внешних SDK, ключи и т.п. вещи, включая глобальные и локальные настройки геймплея. В общем случае все такие данные можно разделить на четыре типа:

    • String
    • Int
    • Float
    • Bool

    Все остальные данные можно легко уложить в них, а с учётом строки, хранить можно что угодно, используя JSON-сериализацию. Воспользуемся в качестве основы ScriptableObject, который подходит для решения этой задачи, как никто.

    public class Setting : ScriptableObject
    {    
        public enum ParameterTypeEnum
        {
            Float,
            Int,
            String,
            Bool
        }
    
        [Serializable]
        public class ParameterData
        {
            public string Name => _name;
            public ParameterTypeEnum ParameterType => _parameterType;
            public string DefaultValue => _defaultValue;
    
            [SerializeField] private string _name;
            [SerializeField] private ParameterTypeEnum _parameterType;
            [SerializeField] private string _defaultValue;
        }
    
        [SerializeField] protected ParameterData[] Parameters;
    }
    

    Итак, в базе, мы имеем массив значений, представляющих из себя:

    • Имя параметра
    • Тип параметра
    • Значения параметра в виде строки

    Примечание: почему строки? Мне показалось это более удобным, чем хранить 4 переменные разных типов.

    Для использования в коде, добавим вспомогательные методы и словарь, который будет хранить конвертированные значения в упакованном (boxing) виде.
    protected readonly IDictionary<string, object> settingParameters = new Dictionary<string, object>();
    
    [NonSerialized] protected bool initialized;
    
        private void OnEnable()
        {
    #if UNITY_EDITOR
            if (EditorApplication.isPlayingOrWillChangePlaymode)
            {
                Initialization();
            }
    #else
            Initialization();
    #endif
        }
    
    public virtual T GetParameterValue<T>(string name)
    {
        if (settingParameters.ContainsKey(name))
        {
            var parameterValue = (T)settingParameters[name];
    
            return parameterValue;
        }
        else
        {
            Debug.Log("[Setting]: name not found [{0}]".Fmt(name));
        }
    
        return default;
    }
    
    protected virtual void Initialization()
    {        
        if (initialized || Parameters == null) return;
    
        for (var i = 0; i < Parameters.Length; i++)
        {
            var parameter = Parameters[i];
    
            object parameterValue = null;
    
            switch (parameter.ParameterType)
            {
                case ParameterTypeEnum.Float:
                {
                     if (!float.TryParse(parameter.DefaultValue, out float value))
                     {
                         value = default;
                     }
    
                     parameterValue = GetValue(parameter.Name, value);
                }
                break;
                case ParameterTypeEnum.Int:
                    {
                        if (!int.TryParse(parameter.DefaultValue, out int value))
                        {
                            value = default;
                        }
    
                        parameterValue = GetValue(parameter.Name, value);
                    }
                    break;
                case ParameterTypeEnum.String:
                {
                     parameterValue = GetValue(parameter.Name, parameter.DefaultValue);
                }
                break;
                case ParameterTypeEnum.Bool:
                {
                     if (!bool.TryParse(parameter.DefaultValue, out bool value))
                     {
                          value = default;
                     }
    
                     parameterValue = GetValue(parameter.Name, value);
                 }
                 break;
             }
    
             settingParameters.Add(parameter.Name, parameterValue);
        }
    
        initialized = true;
    }           
    
    protected virtual object GetValue<T>(string paramName, T defaultValue)
    {
        return defaultValue;
    }
    


    Инициализация выполняется в OnEnable. Почему не в Awake? Этот метод не вызывается для экземпляров, хранимых как ассет (вызывается он в момент CreateInstance, что нам не нужно). В момент запуска приложения для ассетов ScriptableObject вызывается сначала OnDisable (только в редакторе), затем OnEnable. Также, чтобы в редакторе при каждой перекомпиляции и открытии проекта не срабатывала инициализация, нужно добавить директивы предкомпиляции, а в начале файла вставить:
    #if UNITY_EDITOR
    using UnityEditor;
    #endif
    

    Метод GetValue понадобиться нам далее, а для внутренних настроек, он просто возвращает значение по умолчанию.

    Метод GetParameterValue наш основной метод для доступа к параметрам. Здесь стоит учесть, что несмотря на unboxing значений, параметры хранимые в Setting это в некоем роде константы, поэтому их следует забирать при инициализации сцен. Не стоит вызывать метод в Update.

    Пример использования:

    public class MyLogic : MonoBehaviour
    {
        [SerializeField] private Setting _localSetting;
    
        private string _localStrValue;
        private int _localIntValue;
        private float _localFloatValue;
        private bool _localBoolValue;
    
        private void Start()
        {
            _localStrValue = _localSetting.GetParameterValue<string>("MyStr");
            _localIntValue = _localSetting.GetParameterValue<int>("MyInt");
            _localFloatValue = _localSetting.GetParameterValue<float>("MyFloat");
            _localBoolValue = _localSetting.GetParameterValue<bool>("MyBool");
        }
    }
    

    Мы написали основу и теперь нам нужен редактор, поскольку основной целью для нас было именно удобство для тех, кто работает с этими настройками.

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

    CreateAssetMenu(fileName = "New Setting", menuName = "Setting")
    

    Теперь напишем кастомный инспектор, который позволит выводить данные по ассету и запускать внешний редактор.
    [CustomEditor(typeof(Setting), true)]
    public class SettingCustomInspector : Editor
    {
        private GUIStyle _paramsStyle;
        private GUIStyle _paramInfoStyle;
    
        private const string _parameterInfo = "<color=white>Name</color><color=grey> = </color><color=yellow>{0}</color>  <color=white>Type</color><color=grey> = </color><color=yellow>{1}</color>  <color=white>Defualt Value</color><color=grey> = </color><color=yellow>{2}</color>";
    
        public override void OnInspectorGUI()
        {
           if (GUILayout.Button("Edit Setting"))
           {
                SettingEditorWindow.Show(serializedObject.targetObject as Setting); 
           }
    
           EditorGUILayout.LabelField("Parameters:", _parametersStyle, GUILayout.ExpandWidth(true));
    
           var paramsProp = serializedObject.FindProperty("Parameters");
    
           for (var i = 0; i < paramsProp.arraySize; i++)
           {
                var paramProp = paramsProp.GetArrayElementAtIndex(i);
                var paramNameProp = paramProp.FindPropertyRelative("_name");
                var paramTypeProp = paramProp.FindPropertyRelative("_parameterType");
                var paramDefaultValueProp = paramProp.FindPropertyRelative("_defaultValue");
    
                EditorGUILayout.BeginHorizontal();
                                                   
                EditorGUILayout.LabelField(_paramInfo.Fmt(
                                 paramNameProp.stringValue, 
                                 paramTypeProp.enumDisplayNames[paramTypeProp.enumValueIndex],
                                 paramDefaultValueProp.stringValue), 
                                 _paramInfoStyle);
    
                EditorGUILayout.EndHorizontal();
            }
        }
    
        private void PrepareGUIStyle()
        {
            if (_parametersStyle == null)
            {
                _paramsStyle = new GUIStyle(GUI.skin.label);
                _paramsStyle.fontStyle = FontStyle.Bold;
                _paramsStyle.fontSize = 12;
                _paramsStyle.normal.textColor = Color.green;
    
                _paramInfoStyle = new GUIStyle(GUI.skin.label);
                _paramInfoStyle.richText = true;
             }
         }
    }
    


    Вот так это будет выглядеть:

    image

    Теперь нам нужен редактор самих параметров и их значений, для этого используем кастомное окно.
    public class SettingEditorWindow : EditorWindow
    {
        public Setting SelectedAsset;
    
        private int _currentSelectedAsset = -1;
    
        private readonly List<string> _assetNames = new List<string>();
    
        private readonly IList<SerializedObject> _settingSerializationObjects = new List<SerializedObject>();
        private readonly IList<T> _assets = new List<T>();
        private readonly IList<int> _editedNames = new List<int>();;
    	
        private GUIContent _editNameIconContent;
    
        private GUIStyle _headerStyle;
        private GUIStyle _parametersStyle;
        private GUIStyle _parameterHeaderStyle;
        private GUIStyle _nameStyle;
    
        private Vector2 _scrollInspectorPosition = Vector2.zero;
        private Vector2 _scrollAssetsPosition = Vector2.zero;
    
        private const string _SELECTED_ASSET_STR = "SettingSelected";
    
        public static void Show(Setting asset)
        {
            var instance = GetWindow<Setting>(true);
    
            instance.title = new GUIContent("Settings Editor", string.Empty);
            instance.SelectedAsset = asset;
        }
    
        private void OnEnable()
        {
            var assetGuids = AssetDatabase.FindAssets("t:{0}".Fmt(typeof(Setting).Name));
    
            foreach (var guid in assetGuids)
            {
                var path = AssetDatabase.GUIDToAssetPath(guid);
                var asset = AssetDatabase.LoadAssetAtPath<T>(path);
    
                _assetNames.Add(path.Replace("Assets/", "").Replace(".asset", ""));
                _assets.Add(asset);
                _settingSerializationObjects.Add(new SerializedObject(asset));
            }
    
            _currentSelectedAsset = PlayerPrefs.GetInt(_SELECTED_ASSET_STR, -1);
    
            _editNameIconContent = new GUIContent(EditorGUIUtility.IconContent("editicon.sml"));
        }
    
        private void OnDisable()
        {
            PlayerPrefs.SetInt(_SELECTED_ASSET_STR, _currentSelectedAsset);
        }
    
        private void PrepareGUIStyle()
        {
            if (_headerStyle == null)
            {
                _headerStyle = new GUIStyle(GUI.skin.box);
                _headerStyle.fontStyle = FontStyle.Bold;
                _headerStyle.fontSize = 14;
                _headerStyle.normal.textColor = Color.white;
                _headerStyle.alignment = TextAnchor.MiddleCenter;
    
                _parametersStyle = new GUIStyle(GUI.skin.label);
                _parametersStyle.fontStyle = FontStyle.Bold;
                _parametersStyle.fontSize = 12;
                _parametersStyle.normal.textColor = Color.green;
            }
        }
    
        private void OnGUI()
        {
            PrepareGUIStyle();
    
            if (SelectedAsset != null)
            {
                _currentSelectedAsset = _assets.IndexOf(SelectedAsset);
    
                SelectedAsset = null;
            }
    
            EditorGUILayout.BeginHorizontal();
    
            EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.MinWidth(350f), GUILayout.ExpandHeight(true));
            _scrollAssetsPosition = EditorGUILayout.BeginScrollView(_scrollAssetsPosition, GUIStyle.none, GUI.skin.verticalScrollbar);
            _currentSelectedAsset = GUILayout.SelectionGrid(_currentSelectedAsset, _assetNames.ToArray(), 1);
            EditorGUILayout.EndScrollView();
    
            EditorGUILayout.EndVertical();
    
            EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true));
    
            var assetSerializedObject = (_currentSelectedAsset >= 0) ? _settingSerializationObjects[_currentSelectedAsset] : null;
    
            EditorGUILayout.Space();
            EditorGUILayout.LabelField((_currentSelectedAsset >= 0) ? _assetNames[_currentSelectedAsset] : "Select Asset...", _headerStyle, GUILayout.ExpandWidth(true));
            EditorGUILayout.Space();
    
            _scrollInspectorPosition = EditorGUILayout.BeginScrollView(_scrollInspectorPosition, GUIStyle.none, GUI.skin.verticalScrollbar);
            Draw(assetSerializedObject);
            EditorGUILayout.EndScrollView();
    
            EditorGUILayout.EndVertical();
    
            EditorGUILayout.EndHorizontal();
    
            assetSerializedObject?.ApplyModifiedProperties();
        }
    
        private void Draw(SerializedObject assetSerializationObject)
        {
            if (assetSerializationObject == null) return;
    
            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("Parameters:", _parametersStyle, GUILayout.Width(20f), GUILayout.ExpandWidth(true));
    
            var parametersProperty = assetSerializationObject.FindProperty("Parameters");
    
            if (GUILayout.Button("Add", GUILayout.MaxWidth(40f)))
            {
                if (parametersProperty != null)
                {
                    parametersProperty.InsertArrayElementAtIndex(parametersProperty.arraySize);
                }
            }
    
            EditorGUILayout.EndHorizontal();
            EditorGUILayout.Space();
    
            if (parametersProperty != null)
            {
                for (var i = 0; i < parametersProperty.arraySize; i++)
                {
                    var parameterProperty = parametersProperty.GetArrayElementAtIndex(i);
                    var parameterNameProperty = parameterProperty.FindPropertyRelative("_name");
                    var parameterTypeProperty = parameterProperty.FindPropertyRelative("_parameterType");
                    var parameterDefaultValueProperty = parameterProperty.FindPropertyRelative("_defaultValue");
    
                    EditorGUILayout.BeginHorizontal();
    
                    if (GUILayout.Button(_editNameIconContent, GUILayout.MaxWidth(25f), GUILayout.MaxHeight(18f)))
                    {
                        if (_editedNames.Contains(i))
                        {
                            _editedNames.Remove(i);
                        }
                        else
                        {
                            _editedNames.Add(i);
                        }
                    }
    
                    EditorGUILayout.LabelField("Name", _parameterHeaderStyle, GUILayout.MaxWidth(40f));
    
                    if (_editedNames.Contains(i))
                    {
                        parameterNameProperty.stringValue = EditorGUILayout.TextField(parameterNameProperty.stringValue, GUILayout.Width(175f));
    
                        var ev = Event.current;
    
                        if (ev.type == EventType.MouseDown || ev.type == EventType.Ignore || (ev.type == EventType.KeyDown && ev.keyCode == KeyCode.Return))
                        {
                            _editedNames.Remove(i);
                        }
                    }
                    else
                    {
                        EditorGUILayout.LabelField(parameterNameProperty.stringValue, _nameStyle, GUILayout.Width(175f));
                    }
    
                    EditorGUILayout.LabelField("Type", _parameterHeaderStyle, GUILayout.MaxWidth(40f));
                    parameterTypeProperty.enumValueIndex = EditorGUILayout.Popup(parameterTypeProperty.enumValueIndex,
                                                                                 parameterTypeProperty.enumDisplayNames,
                                                                                 GUILayout.Width(75f));
    
                    GUILayout.Space(20f);
                    EditorGUILayout.LabelField("DefaultValue", _parameterHeaderStyle, GUILayout.Width(85f));
    
                    switch (parameterTypeProperty.enumValueIndex)
                        {
                            case 0:
                                {                                
                                    if (!float.TryParse(parameterDefaultValueProperty.stringValue, out float value))
                                    {
                                        value = default;
                                    }
    
                                    value = EditorGUILayout.FloatField(value, GUILayout.ExpandWidth(true));
    
                                    parameterDefaultValueProperty.stringValue = value.ToString();
                                }
                                break;
                            case 1:
                                {
                                    if (!int.TryParse(parameterDefaultValueProperty.stringValue, out int value))
                                    {
                                        value = default;
                                    }
    
                                    value = EditorGUILayout.IntField(value, GUILayout.ExpandWidth(true));
    
                                    parameterDefaultValueProperty.stringValue = value.ToString();
                                }
                                break;
                            case 2:
                                parameterDefaultValueProperty.stringValue = EditorGUILayout.TextField(parameterDefaultValueProperty.stringValue, GUILayout.ExpandWidth(true));
                                break;
                            case 3:
                                {                                
                                    if (!bool.TryParse(parameterDefaultValueProperty.stringValue, out bool value))
                                    {
                                        value = default;
                                    }
    
                                    value = EditorGUILayout.Toggle(value, GUILayout.ExpandWidth(true));
    
                                    parameterDefaultValueProperty.stringValue = value.ToString();
                                }
                                break;
                        }
    
                    if (GUILayout.Button("-", GUILayout.MaxWidth(25f), GUILayout.MaxHeight(18f)))
                    {
                        if (_editedNames.Contains(i))
                        {
                            _editedNames.Remove(i);
                        }
    
                        parametersProperty.DeleteArrayElementAtIndex(i);
                    }
    
                    EditorGUILayout.EndHorizontal();
                }
            }
        }
    }
    


    Пояснять код сильно не буду, здесь в целом все просто. Отмечу только, что редактор позволяет редактировать по выбору все ассеты типа Setting. Для этого при открытии окна мы находим их в проекте с помощью метода AssetDatabase.FindAssets(«t:{0}».Fmt(typeof(Setting).Name)). А также редактирование имени параметра сделано через кнопку для того, чтобы исключить его случайное изменение.

    Вот так выглядит редактор:

    image

    Мы рассмотрели настройки, используемые внутри приложения, теперь рассмотрим более специфичный случай.

    Внешние настройки


    Представим себе ситуацию, что в уже запущенной игре, нам вдруг понадобилось изменить некие значения, чтобы скорректировать игровой процесс. В примитивном варианте, мы это изменяем в билде, накапливаем такие изменения, делаем обновление и отправляем в магазины, после чего ждем подтверждения и т.п. Но как бы с теми, кто не обновит приложение? И что, если изменения нужно внести срочно? Для решения этой задачи существует такой механизм как Remote Settings. Это не новое изобретение и используется во многих сторонних SDK для аналитики и т.п., например — это есть в Firebase, в GameAnalytics, а также в Unity Analytics. Именно последнее мы и будем использовать.

    Примечание: в целом разницы между всеми этими системами нет, они схожи и используют одинаковые принципы.

    Остановимся подробнее на том, что же такое Remote Settings в Unity Analytics и что он умеет.

    Для того, чтобы данный функционал стал доступен в проекте, необходим включить аналитику в проекте на вкладке Services.

    image

    После этого необходимо зайти в свой аккаунт Unity3d и найти там свой проект и перейти по ссылке в раздел аналитики, где слева в меню выбираем пункт Remote Settings.

    image

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

    image

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

    image

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

    Примечание: кнопка Sync осуществляет синхронизацию параметров с приложением. Этот процесс не происходит мгновенно, однако в момент, когда параметры в приложении обновятся, будут вызваны соответствующие события, о них мы поговорим позже.

    Для работы с Remote Settings не требуется каких-либо дополнительных SDK, достаточно включить аналитику, о чем я писал выше.

    Напишем класс, для работы с удаленными настройками, для этого используем описанный выше класс Setting в качестве базы.
    public sealed class RemoteSetting : Setting
    {     
        public IList<string> GetUpdatedParameter()
        {
            var updatedParameters = new List<string>();
    
            for (var i = 0; i < Parameters.Length; i++)
            {
                var parameter = Parameters[i];
    
                switch (parameter.ParameterType)
                {
                    case ParameterTypeEnum.Float:
                        {
                            var currentValue = Get<float>(parameter.Name);
                            var newValue = RemoteSettings.GetFloat(parameter.Name, currentValue);
    
                            if (currentValue != newValue)
                            {
                                settingParameters[parameter.Name] = newValue;
                                updatedParameters.Add(parameter.Name);
                            }
                        }
                        break;
                    case ParameterTypeEnum.Int:
                        {
                            var currentValue = Get<int>(parameter.Name);
                            var newValue = RemoteSettings.GetInt(parameter.Name, currentValue);
    
                            if (currentValue != newValue)
                            {
                                settingParameters[parameter.Name] = newValue;
                                updatedParameters.Add(parameter.Name);
                            }
                        }
                        break;
                    case ParameterTypeEnum.String:
                        {
                            var currentValue = Get<string>(parameter.Name);
                            var newValue = RemoteSettings.GetString(parameter.Name, currentValue);
    
                            if (string.Compare(currentValue, newValue, System.StringComparison.Ordinal) != 0)
                            {
                                settingParameters[parameter.Name] = newValue;
                                updatedParameters.Add(parameter.Name);
                            }
                        }
                        break;
                    case ParameterTypeEnum.Bool:
                        {
                            var currentValue = Get<bool>(parameter.Name);
                            var newValue = RemoteSettings.GetBool(parameter.Name, currentValue);
    
                            if (currentValue != newValue)
                            {
                                settingParameters[parameter.Name] = newValue;
                                updatedParameters.Add(parameter.Name);
                            }
                        }
                        break;
    
                }
            }
    
            return updatedParameters;
        }
    
        protected override object GetValue<T>(string paramName, T defaultValue)
        {
            switch(defaultValue)
            {
                case float f:
                    return RemoteSettings.GetFloat(paramName, f);                
                case int i:
                    return RemoteSettings.GetInt(paramName, i);                
                case string s:
                    return RemoteSettings.GetString(paramName, s);                
                case bool b:
                    return RemoteSettings.GetBool(paramName, b);
                default:
                    return default;
            }        
        }
    }
    


    Как видно мы переопределили метод GetValue и добавили новый метод, который позволяет получить список измененных параметров, он понадобиться нам позже.

    Выше мы писали пример использования Setting в коде, он достаточно простой, однако не учитывает наличие удаленных настроек, поэтому для унификации доступа ко всем настройкам в едином ключе, напишем менеджер, который будет в этом помогать.

    Код менеджера настроек
    public class SettingsManager : MonoBehaviourSingleton<SettingsManager>
    {
        public Setting this[string index] => GetSetting(index);
    
        [SerializeField] private Setting[] _settings;
    
        private readonly IDictionary<string, Setting> _settingsByName = new Dictionary<string, Setting>();
    
        public void ForceUpdate()
        {
            RemoteSettings.ForceUpdate();
        }
    
        private void Start()
        {
            foreach(var setting in _settings)
            {
                _settingsByName.Add(setting.name, setting);
            }
    
            RemoteSettings.BeforeFetchFromServer += OnRemoteSettingBeforeUpdate;
            RemoteSettings.Updated += OnRemoteSettingsUpdated;
            RemoteSettings.Completed += OnRemoteSettingCompleted;
        }
    
        private Setting GetSetting(string name)
        {
            if(_settingsByName.ContainsKey(name))
            {
                return _settingsByName[name];
            }else
            {
                Debug.LogWarningFormat("[SettingManager]: setting name [{0}] not found", name);
    
                return null;
            }
        }
    
        private void OnRemoteSettingBeforeUpdate()
        {
            RemoteSettingBeforeUpdate.Call();
        }
    
        private void OnRemoteSettingsUpdated()
        {
            foreach (var setting in _settingsByName.Values)
            {
                if (setting is RemoteSetting)
                {
                    var updatedParameter = remoteSetting.GetUpdatedParameter();
    
                    foreach (var parameterName in updatedParameter)
                    {
                        RemoteSettingUpdated.Call(parameterName);
                    }
                }
            }
        }
    
        private void OnRemoteSettingCompleted(bool wasUpdatedFromServer, bool settingsChanged, int serverResponse)
        {
            RemoteSettingsCompleted.Call(wasUpdatedFromServer, settingsChanged, serverResponse);        
        }
    
        private void OnDestroy()
        {
     RemoteSettings.BeforeFetchFromServer -= OnRemoteSettingBeforeUpdate;
            RemoteSettings.Updated -= OnRemoteSettingsUpdated;
            RemoteSettings.Completed -= OnRemoteSettingCompleted;
        }
    } 
    


    Менеджер представлен в виде сиглетона, который живет только в сцене. Это сделано для простоты обращения к нему и для того, чтобы можно было легко управлять набором параметров в каждой сцене (исключить параметры, которые не требуются по логике).

    Как видно у RemoteSettings есть три события:

    1. Событие, вызываемое перед тем, как будут получены значения параметров с удаленного сервера
    2. Событие обновления параметров (вызывается как раз по кнопке Sync, о которой мы писали ранее), а также в случае принудительного обновления параметров через функцию ForceUpdate
    3. Событие вызываемое, когда с сервера будут получены данные о удаленных настройках. Здесь также выдается код ответа сервера, в случае если произойдет какая-либо ошибка.

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

    Примечание: необходимо понимать, как работает RemoteSettings. На старте, если есть доступ в интернет, он автоматически скачивает данные о параметрах и кэширует их, поэтому при следующем запуске, если интернет отсутствуют, данные будут взяты из кэша. Исключение составляет ситуация, когда приложение изначально запускается с выключенным доступом в сеть, в этом случае, функции получения значения параметра вернут значение по умолчанию. В нашем случае — это те, которые мы вводим в редакторе.

    Изменим теперь пример использования настроек из кода с учетом вышеописанного.

    public class MyLogic : MonoBehaviour
    {
        private const string INGAME_PARAMETERS = "IngamgeParameters";
        private const string REMOTE_RAPAMETERS = "RemoteParamteters";
    
        private string _localStrValue;
        private int _localIntValue;
        private float _localFloatValue;
        private bool _localBoolValue;
    
        private string _remoteStrValue;
        private int _remoteIntValue;
        private float _remoteFloatValue;
        private bool _remoteBoolValue;
    
        private void Start()
        {        
            var ingameParametes = SettingsManager.Instance[INGAME_PARAMETERS];
            var remoteParametes = SettingsManager.Instance[REMOTE_RAPAMETERS];
    
            _localStrValue = ingameParametes.GetParameterValue<string>("MyStr");
            _localIntValue = ingameParametes.GetParameterValue<int>("MyInt");
            _localFloatValue = ingameParametes.GetParameterValue<float>("MyFloat");
            _localBoolValue = ingameParametes.GetParameterValue<bool>("MyBool");
    
            _remoteStrValue = remoteParametes.GetParameterValue<string>("MyStr");
            _remoteIntValue = remoteParametes.GetParameterValue<int>("MyInt");
            _remoteFloatValue = remoteParametes.GetParameterValue<float>("MyFloat");
            _remoteBoolValue = remoteParametes.GetParameterValue<bool>("MyBool");
        }
    }
    

    Как видим, из кода, разницы в работе между внутренними настройками и внешними нет, однако при необходимости, если того требует логика, можно подписаться на события менеджера, связанные с удаленными настройками.

    Примечание: если нужны только удаленные параметры, то можно скачать специальный плагин из AssetStore, он позволяет работать с ними сразу.

    Заключение


    В данной статье я постарался показать, как можно просто конфигурировать приложение написанное на Unity3d используя, как внутренние настройки, так и удаленные. Аналогичный подход я использую в своих проектах, и он доказывает свою эффективность. Нам даже удалось, используя удаленные настройки реализовать свою систему A/B тестирования. Помимо этого, настройки повсеместно применяются для хранения различных констант связанных с SDK, с серверными вещами, а также с настройкой игрового процесса и т.п. Гейм-дизайнер может заранее создать набор параметров и описать как, и для чего, и где они используются, при этом он может настраивать игровой процесс не блокируя сцену. А за счет того, что мы использовали ScriptableObject и храним такие параметры как ассеты, их можно загружать через AssetBundle, что еще более расширяет нам возможности.

    Ссылки указанные в статье:

    habr.com/ru/post/282524
    assetstore.unity.com/packages/add-ons/services/analytics/unity-analytics-remote-settings-89317
    • +19
    • 3,3k
    • 5
    Поделиться публикацией

    Похожие публикации

    Комментарии 5

      0
      Почему нельзя просто захардкодить или законфигать внутренние настройки? Они же после компиляции не меняются. Так пусть мы и будем их компилировать и использовать аля
      var x = LocalSettings.Global.Environment.TimeSpeed;
        +2
        Можно, но это касается переменных, которые не меняют дизайнеры, ваш варианет не исключает, а дополняет описанный в статье.
        Представьте, если все такие данные в коде, их настройка превратиться в кошмар, изменил, перекомпилировал, запустил, посмотрел, повторил.
        В случае со ScriptableObject перекомпиляции не нужно — изменил, запустил.
          –1
          Разве при запуск проект не перекомпилируется? Мне казалось, что именно так и происходит.
            0
            Смотря что запускать. Если бинарники, то просто запускаются бинарники.
            А что вы предлагаете модифицировать перекомпиляцией — дальность обзора, очки оружия, цвет флага?
              –1
              Да, все настройки, которые требуется настраивать дизайнерам и сборщикам в проекте. Это же часть проекта. А проект в юнити принято зашивать в немодифицируемый архив и бандлы. Окей, раз решили не показывать игрокам внутренности, то и не будем нарушать это правило.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое