Просто о внутренних и внешних настройках для приложения в 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 переменные разных типов.
protected readonly IDictionary settingParameters = new Dictionary();
[NonSerialized] protected bool initialized;
private void OnEnable()
{
Initialization();
}
public virtual T GetParameterValue(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(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("MyStr");
_localIntValue = _localSetting.GetParameterValue("MyInt");
_localFloatValue = _localSetting.GetParameterValue("MyFloat");
_localBoolValue = _localSetting.GetParameterValue("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 = "Name = {0} Type = {1} Defualt Value = {2} ";
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;
}
}
}
Вот так это будет выглядеть:
public class SettingEditorWindow : EditorWindow
{
public Setting SelectedAsset;
private int _currentSelectedAsset = -1;
private readonly List _assetNames = new List();
private readonly IList _settingSerializationObjects = new List();
private readonly IList _assets = new List();
private readonly IList _editedNames = new List();;
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(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(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)). А также редактирование имени параметра сделано через кнопку для того, чтобы исключить его случайное изменение.
Вот так выглядит редактор:
Мы рассмотрели настройки, используемые внутри приложения, теперь рассмотрим более специфичный случай.
Внешние настройки
Представим себе ситуацию, что в уже запущенной игре, нам вдруг понадобилось изменить некие значения, чтобы скорректировать игровой процесс. В примитивном варианте, мы это изменяем в билде, накапливаем такие изменения, делаем обновление и отправляем в магазины, после чего ждем подтверждения и т.п. Но как бы с теми, кто не обновит приложение? И что, если изменения нужно внести срочно? Для решения этой задачи существует такой механизм как Remote Settings. Это не новое изобретение и используется во многих сторонних SDK для аналитики и т.п., например — это есть в Firebase, в GameAnalytics, а также в Unity Analytics. Именно последнее мы и будем использовать.
Примечание: в целом разницы между всеми этими системами нет, они схожи и используют одинаковые принципы.
Остановимся подробнее на том, что же такое Remote Settings в Unity Analytics и что он умеет.
Для того, чтобы данный функционал стал доступен в проекте, необходим включить аналитику в проекте на вкладке Services.
После этого необходимо зайти в свой аккаунт Unity3d и найти там свой проект и перейти по ссылке в раздел аналитики, где слева в меню выбираем пункт Remote Settings.
Все настройки разделяются на те, что используются в режиме разработки и те, которые будут использоваться в уже выпущенном приложении.
Для добавления параметра выбираем соответствующий пункт и вводим имя, тип и значение параметра.
После того, как мы добавили все нужные параметры, нам необходима поддержка в коде, для работы с ними.
Примечание: кнопка Sync осуществляет синхронизацию параметров с приложением. Этот процесс не происходит мгновенно, однако в момент, когда параметры в приложении обновятся, будут вызваны соответствующие события, о них мы поговорим позже.
Для работы с Remote Settings не требуется каких-либо дополнительных SDK, достаточно включить аналитику, о чем я писал выше.
public sealed class RemoteSetting : Setting
{
public IList GetUpdatedParameter()
{
var updatedParameters = new List();
for (var i = 0; i < Parameters.Length; i++)
{
var parameter = Parameters[i];
switch (parameter.ParameterType)
{
case ParameterTypeEnum.Float:
{
var currentValue = Get(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(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(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(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(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
{
public Setting this[string index] => GetSetting(index);
[SerializeField] private Setting[] _settings;
private readonly IDictionary _settingsByName = new Dictionary();
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 есть три события:
- Событие, вызываемое перед тем, как будут получены значения параметров с удаленного сервера
- Событие обновления параметров (вызывается как раз по кнопке Sync, о которой мы писали ранее), а также в случае принудительного обновления параметров через функцию ForceUpdate
- Событие вызываемое, когда с сервера будут получены данные о удаленных настройках. Здесь также выдается код ответа сервера, в случае если произойдет какая-либо ошибка.
Примечание: в коде используется система событий, построенная на типах данных, подробнее о ней написано в другой моей статье.
Примечание: необходимо понимать, как работает 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("MyStr");
_localIntValue = ingameParametes.GetParameterValue("MyInt");
_localFloatValue = ingameParametes.GetParameterValue("MyFloat");
_localBoolValue = ingameParametes.GetParameterValue("MyBool");
_remoteStrValue = remoteParametes.GetParameterValue("MyStr");
_remoteIntValue = remoteParametes.GetParameterValue("MyInt");
_remoteFloatValue = remoteParametes.GetParameterValue("MyFloat");
_remoteBoolValue = remoteParametes.GetParameterValue("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