📜 ⬆️ ⬇️

Just about the internal and external settings for the application in Unity3D

Introduction


Hello dear readers, in today's article I would like to talk about the settings and configuration of gaming applications created in the Unity3d environment.

By tradition, I'll start with prehistory. During my work in the gaming industry, I developed various projects, with varying complexity, and visited both the game design camp and the programmer camp (where I am today). It's no secret that any application requires a large number of different configuration data and settings. In the classic, relatively Unity3d form, such settings are made in the visible part of the inspector, where some numbers are entered, etc. I think that talking about the convenience of this approach is not worth it, even if we exclude the fact that during the setup, the scene in which the MonoBehaviour class is located is blocked for changes by other developers. Therefore, after a series of all sorts of ordeals in the past, I decided to write something simple and effective that will make life easier for everyone and simplify work with such data, and I want to share it with you.

Note : All the code described below applies to Unity version 2018.3+ and uses the Roslyn compiler (C # 7+ language version).

Internal settings


To begin, consider the internal settings of the project, which include various constants, links, external SDK identifiers, keys, etc. things including global and local gameplay settings. In general, all such data can be divided into four types:


All other data can be easily stored in them, and taking into account the string, you can store anything using JSON serialization. Let's use ScriptableObject as a basis, which is suitable for solving this task like no one else.

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; } 

So, in the database, we have an array of values ​​that represent:


Note : why strings? It seemed to me more convenient than storing 4 variables of different types.

For use in the code, we will add auxiliary methods and a dictionary that will store the converted values ​​in boxed form.
 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; } 


Initialization is done in OnEnable . Why not in awake ? This method is not called for instances stored as an asset (it is called at the time of CreateInstance , which we do not need). At the moment of launching the application for assets, ScriptableObject is first called OnDisable (only in the editor), then OnEnable . Also, in order to avoid initialization in the editor each time you recompile and open a project, you need to add precompilation directives and insert at the beginning of the file:
 #if UNITY_EDITOR using UnityEditor; #endif 

We need the GetValue method further, and for internal settings, it simply returns the default value.

The GetParameterValue method is our main method for accessing parameters. It is worth considering here that despite the unboxing of values, the parameters stored in Setting are in some kind of constant, so you should take them when initializing the scenes. Do not call the method in Update .

Usage example:

 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"); } } 

We wrote the foundation and now we need an editor, because the main goal for us was exactly the convenience for those who work with these settings.

To add a menu item in order to be able to create an asset, you can use the attribute:

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

Now we will write a custom inspector, which will allow to display data on the asset and launch an external editor.
 [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; } } } 


This is how it will look like:

image

Now we need an editor of the parameters themselves and their values, for this we use a custom window.
 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(); } } } } 


I will not explain the code much, as a whole everything is simple. I note only that the editor allows you to edit by choice all assets of the type Setting . To do this, when you open the window, we find them in the project using the AssetDatabase.FindAssets method ("t: {0}". Fmt (typeof (Setting). Name)) . As well as editing the name of the parameter is done through the button in order to prevent its accidental change.

Here is the editor:

image

We have considered the settings used inside the application, now consider a more specific case.

External settings


Imagine a situation that in an already running game, we suddenly needed to change certain values ​​in order to correct the gameplay. In a primitive version, we change this in the build, accumulate such changes, do an update and send it to stores, then wait for confirmation, etc. But how about those who do not update the application? And what if changes need to be made urgently? To solve this problem, there is a mechanism such as Remote Settings . This is not a new invention and is used in many third-party SDKs for analytics, etc., for example, this is in Firebase , in GameAnalytics , and also in Unity Analytics . It is the last we will use.

Note : in general, there is no difference between all these systems, they are similar and use the same principles.

Let us dwell in more detail on what Remote Settings is in Unity Analytics and what it can do.

In order for this functionality to be available in the project, you need to enable analytics in the project on the Services tab.

image

After this, you need to log into your Unity3d account and find your project there and follow the link to the analytics section, where in the menu on the left we select the item Remote Settings .

image

All settings are divided into those that are used in development mode and those that will be used in an already released application.

image

To add a parameter, select the corresponding item and enter the name, type and value of the parameter.

image

After we have added all the necessary parameters, we need support in the code to work with them.

Note : The Sync button synchronizes the settings with the application. This process does not happen instantly, but at the moment when the parameters in the application are updated, the corresponding events will be triggered, we will talk about them later .

To work with Remote Settings do not require any additional SDK, it is enough to enable analytics, as I wrote above.

Let's write a class to work with remote settings, for this we use the Setting class described above as the base.
 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; } } } 


As you can see, we redefined the GetValue method and added a new method that allows us to get a list of changed parameters, we will need it later.

Above, we wrote an example of using Setting in code, it is quite simple, but does not take into account the presence of remote settings, so to unify access to all settings in a single key, we will write a manager who will help with this.

Settings Manager Code
 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; } } 


The manager is represented as a siglet that lives only in the scene. This is done for ease of reference to it and in order to be able to easily manage the set of parameters in each scene (exclude parameters that are not required by logic).

As you can see, RemoteSettings has three events:

  1. An event that is triggered before parameter values ​​are retrieved from a remote server.
  2. Parameter update event (called just by the Sync button, which we wrote about earlier), as well as in case of forced update of parameters via the ForceUpdate function
  3. Event triggered when remote settings are retrieved from the server. It also gives the server response code, in case of any error.

Note : the code uses an event system built on data types, more details are written about it in my other article .

Note : you need to understand how RemoteSettings works. At the start, if you have access to the Internet, it automatically downloads data about the parameters and caches it, so the next time you start, if the Internet is not available, the data will be taken from the cache. An exception is the situation when the application is initially started with network access turned off, in this case, the function of getting the parameter value will return the default value. In our case, these are the ones that we enter in the editor.

Now let's change the example of using the settings from the code taking into account the above.

 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"); } } 

As you can see, from the code, there is no difference in the work between the internal settings and the external ones; however, if necessary, if the logic requires it, you can subscribe to manager events related to remote settings.

Note : if you only need remote settings, you can download a special plugin from AssetStore , it allows you to work with them immediately.

Conclusion


In this article I tried to show how you can easily configure an application written in Unity3d using both internal settings and remote ones. I use a similar approach in my projects, and it proves its effectiveness. We even managed, using remote settings, to implement our A / B testing system . In addition, the settings are commonly used to store various constants associated with the SDK, with server things, as well as with setting up the gameplay, etc. A game designer can pre-create a set of parameters and describe how, and for what, and where they are used, while he can customize the gameplay without blocking the scene. And due to the fact that we used ScriptableObject and store such parameters as assets, they can be loaded via AssetBundle , which further expands our capabilities.

Links listed in the article :

habr.com/en/post/282524
assetstore.unity.com/packages/add-ons/services/analytics/unity-analytics-remote-settings-89317

Source: https://habr.com/ru/post/436634/