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

в 18:54, , рубрики: remote settings, unity analytics, 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()
{
    Initialization();   
}

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) 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.

Метод 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 paramsProperty = serializedObject.FindProperty("Parameters");

       for (var i = 0; i < parametersProperty.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

Автор: Ichimitsu

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js