2025-05-01 01:48:08 -07:00

495 lines
21 KiB
C#

#if ENABLE_PROPERTY_VARIANTS || PACKAGE_DOCS_GENERATION
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using UnityEngine.Localization.PropertyVariants.TrackedProperties;
using UnityEngine.Localization.Pseudo;
using UnityEngine.Localization.Settings;
using UnityEngine.Pool;
using UnityEngine.ResourceManagement.AsyncOperations;
namespace UnityEngine.Localization.PropertyVariants.TrackedObjects
{
/// <summary>
/// Uses JSON to apply changes to a tracked object.
/// JSON can only be used with MonoBehaviour and ScriptableObject types.
/// </summary>
[Serializable]
public abstract class JsonSerializerTrackedObject : TrackedObject
{
/// <summary>
/// Determines the type of property update that will be performed.
/// </summary>
public enum ApplyChangesMethod
{
/// <summary>
/// Partial update will generate a partial patch and apply the changes only for the tracked properties.
/// Partial update provides improved performance however is not supported when modifying collections or properties that contain a serialized version such as Rect.
/// </summary>
Partial,
/// <summary>
/// Full update will read the entire object into JSON and then patch the properties before reapplying the new JSON.
/// </summary>
Full
}
[Tooltip("Determines the type of property update that will be performed." +
"- Full update reads the entire object into JSON, patches the properties, then reapplies the new JSON.\n" +
"- Partial update generates a partial patch and applies the changes for the tracked properties only.\n" +
"Partial update provides better performance however is not supported when modifying collections or properties that contain a serialized version such as Rect.\n" +
"This value is automatically set based on the properties tracked.")]
[SerializeField]
ApplyChangesMethod m_UpdateType = ApplyChangesMethod.Partial;
/// <summary>
/// Determines the type of property update that will be performed.
/// </summary>
public ApplyChangesMethod UpdateType
{
get => m_UpdateType;
set => m_UpdateType = value;
}
public override void AddTrackedProperty(ITrackedProperty trackedProperty)
{
base.AddTrackedProperty(trackedProperty);
// We can not partially patch array items as we need to know the array size or it will be resized.
if (trackedProperty.PropertyPath.Contains(".Array.data[") || trackedProperty.PropertyPath.EndsWith(".Array.size"))
UpdateType = ApplyChangesMethod.Full;
}
// Reusable class for when we need to wait for an async string operation to complete before we can apply it to a json value.
class DeferredJsonStringOperation
{
public JValue jsonValue;
public readonly Action<AsyncOperationHandle<string>> callback;
public static readonly ObjectPool<DeferredJsonStringOperation> Pool = new ObjectPool<DeferredJsonStringOperation>(
() => new DeferredJsonStringOperation(), collectionCheck: false);
public DeferredJsonStringOperation()
{
callback = OnStringLoaded;
}
void OnStringLoaded(AsyncOperationHandle<string> asyncOperationHandle)
{
jsonValue.Value = asyncOperationHandle.Result;
// Clear
jsonValue = null;
Pool.Release(this);
}
}
// Reusable class for when we need to wait for an async object operation to complete before we can apply it to a json value.
class DeferredJsonObjectOperation
{
public JValue jsonValue;
public readonly Action<AsyncOperationHandle<Object>> callback;
public static readonly ObjectPool<DeferredJsonObjectOperation> Pool = new ObjectPool<DeferredJsonObjectOperation>(
() => new DeferredJsonObjectOperation(), collectionCheck: false);
public DeferredJsonObjectOperation()
{
callback = OnAssetLoaded;
}
void OnAssetLoaded(AsyncOperationHandle<Object> asyncOperationHandle)
{
jsonValue.Value = asyncOperationHandle.Result != null ? asyncOperationHandle.Result.GetInstanceID() : 0;
// Clear
jsonValue = null;
Pool.Release(this);
}
}
public override AsyncOperationHandle ApplyLocale(Locale variantLocale, Locale defaultLocale)
{
if (Target == null)
return default;
// We need to capture a snapshot before, then patch in the changes and reapply.
// We could use partial json that would not require the initial snapshot however this
// has issues when dealing with partial lists and when we are patching to a field/type
// that uses `serializedVersion` such as Rect.
JObject jsonObject;
if (UpdateType == ApplyChangesMethod.Full)
{
var jsonBefore = JsonUtility.ToJson(Target);
jsonObject = JObject.Parse(jsonBefore);
}
else
{
jsonObject = new JObject();
}
var asyncOperations = ListPool<AsyncOperationHandle>.Get();
var arraySizes = ListPool<ArraySizeTrackedProperty>.Get();
var propertyChanged = false;
var defaultLocaleIdentifier = defaultLocale != null ? defaultLocale.Identifier : default;
// In the Editor the instanceID field is used however in the player a different
// serialization path is taken and instanceID ends up getting mapped from m_FileID.
// https://unity.slack.com/archives/C9SQHJGN6/p1623329879079600
// This is now fixed in 2022.2.0a1 - 1342327
#if UNITY_EDITOR || UNITY_2022_2_OR_NEWER
const string instanceIdField = ".instanceID";
#else
const string instanceIdField = ".m_FileID";
#endif
foreach (var property in TrackedProperties)
{
if (property == null)
continue;
#if UNITY_EDITOR
if (!Application.isPlaying)
VariantsPropertyDriver.RegisterProperty(Target, property.PropertyPath);
#endif
switch (property)
{
// Array size property?
case ArraySizeTrackedProperty arraySizeProp:
{
// We defer array size changes until the end so that array items that cause a resize can be handled(e.g remove them).
arraySizes.Add(arraySizeProp);
propertyChanged = true;
break;
}
case IStringProperty stringProperty:
{
var value = stringProperty.GetValueAsString(variantLocale.Identifier, defaultLocaleIdentifier);
if (value != null)
{
var valueContainer = (JValue)GetPropertyFromPath(property.PropertyPath, jsonObject);
valueContainer.Value = variantLocale is PseudoLocale pseudoLocale ? pseudoLocale.GetPseudoString(value) : value;
propertyChanged = true;
}
break;
}
case ITrackedPropertyValue<Object> objectProperty:
{
objectProperty.GetValue(variantLocale.Identifier, defaultLocaleIdentifier, out var value);
var jsonProperty = (JValue)GetPropertyFromPath(property.PropertyPath + instanceIdField, jsonObject);
jsonProperty.Value = value != null ? value.GetInstanceID() : 0;
propertyChanged = true;
break;
}
case LocalizedStringProperty localizedStringProperty:
{
// Ignore emptys
if (localizedStringProperty.LocalizedString.IsEmpty)
break;
localizedStringProperty.LocalizedString.LocaleOverride = variantLocale;
var stringOp = localizedStringProperty.LocalizedString.GetLocalizedStringAsync();
var jsonProperty = (JValue)GetPropertyFromPath(property.PropertyPath, jsonObject);
if (stringOp.IsDone)
{
jsonProperty.Value = stringOp.Result;
AddressablesInterface.Release(stringOp);
}
#if !UNITY_WEBGL // WebGL does not support WaitForCompletion
else if (localizedStringProperty.LocalizedString.ForceSynchronous)
{
jsonProperty.Value = stringOp.WaitForCompletion();
AddressablesInterface.Release(stringOp);
}
#endif
else
{
var asyncHandler = DeferredJsonStringOperation.Pool.Get();
asyncHandler.jsonValue = jsonProperty;
stringOp.Completed += asyncHandler.callback;
asyncOperations.Add(stringOp);
}
propertyChanged = true;
break;
}
case LocalizedAssetProperty localizedAssetProperty:
{
// Ignore emptys
if (localizedAssetProperty.LocalizedObject.IsEmpty)
break;
localizedAssetProperty.LocalizedObject.LocaleOverride = variantLocale;
var assetOp = localizedAssetProperty.LocalizedObject.LoadAssetAsObjectAsync();
var jsonProperty = (JValue)GetPropertyFromPath(property.PropertyPath + instanceIdField, jsonObject);
if (assetOp.IsDone)
{
var result = assetOp.Result;
jsonProperty.Value = result != null ? result.GetInstanceID() : 0;
AddressablesInterface.Release(assetOp);
}
#if !UNITY_WEBGL // WebGL does not support WaitForCompletion
else if (localizedAssetProperty.LocalizedObject.ForceSynchronous)
{
var result = assetOp.WaitForCompletion();
jsonProperty.Value = result != null ? result.GetInstanceID() : 0;
AddressablesInterface.Release(assetOp);
}
#endif
else
{
var asyncHandler = DeferredJsonObjectOperation.Pool.Get();
asyncHandler.jsonValue = jsonProperty;
assetOp.Completed += asyncHandler.callback;
asyncOperations.Add(assetOp);
}
propertyChanged = true;
break;
}
}
}
if (asyncOperations.Count > 0)
{
var operation = AddressablesInterface.CreateGroupOperation(asyncOperations);
operation.Completed += res =>
{
ApplyArraySizes(arraySizes, jsonObject, variantLocale.Identifier, defaultLocaleIdentifier);
ApplyJson(jsonObject);
foreach (var op in res.Result)
AddressablesInterface.Release(op);
ListPool<AsyncOperationHandle>.Release(asyncOperations);
ListPool<ArraySizeTrackedProperty>.Release(arraySizes);
AddressablesInterface.Release(res);
};
return operation;
}
if (propertyChanged)
{
ApplyArraySizes(arraySizes, jsonObject, variantLocale.Identifier, defaultLocaleIdentifier);
ApplyJson(jsonObject);
}
ListPool<AsyncOperationHandle>.Release(asyncOperations);
ListPool<ArraySizeTrackedProperty>.Release(arraySizes);
return default;
}
void ApplyArraySizes(IEnumerable<ArraySizeTrackedProperty> arraySizes, JObject jsonObject, LocaleIdentifier variantLocale, LocaleIdentifier defaultLocale)
{
// If we are modifying items in the array then we always store a default value, we assume it will always exist. If the item does not exist,
// such as when the array was resized then we need to first apply the default value and then change the array size which may result in the item being removed.
foreach (var property in arraySizes)
{
var jsonContainer = (JArray)GetPropertyFromPath(property.PropertyPath, jsonObject);
if (!property.GetValue(variantLocale, defaultLocale, out var newSize)) continue;
if (jsonContainer.Count > newSize)
{
while (jsonContainer.Count > newSize)
{
jsonContainer.RemoveAt(jsonContainer.Count - 1);
}
}
else if (jsonContainer.Count < newSize)
{
while (jsonContainer.Count < newSize)
{
jsonContainer.Add(new JObject());
}
}
}
}
void ApplyJson(JObject jsonObject)
{
#if LOCALIZATION_DEBUG_JSON
var json = jsonObject.ToString(Formatting.Indented);
Debug.Log(json);
#else
var json = jsonObject.ToString();
#endif
JsonUtility.FromJsonOverwrite(json, Target);
PostApplyTrackedProperties();
}
internal struct ArrayResult
{
public string path;
public int arrayStartIndex;
public int arrayDataIndexStart;
public int arrayDataIndexEnd;
// Is this an array size path?
public bool IsArraySize => arrayStartIndex != -1 && arrayDataIndexStart == -1;
// Is this an array element path? That is an item that ends in data[x], if not then it could be an item inside of an array element.
public bool IsArrayElement => path?.Length == arrayDataIndexEnd + 1;
public int GetDataIndex()
{
if (arrayDataIndexStart == -1)
return -1;
var indexString = path.Substring(arrayDataIndexStart, arrayDataIndexEnd - arrayDataIndexStart);
if (uint.TryParse(indexString, out var index))
return (int)index;
Debug.LogError($"Failed to parse Array index `{indexString}` from property path `{path}`");
return -1;
}
public ArrayResult(string p, int start, int bracketStart, int bracketEnd)
{
path = p;
arrayStartIndex = start;
arrayDataIndexStart = bracketStart;
arrayDataIndexEnd = bracketEnd;
}
}
internal static ArrayResult GetNextArrayItem(string path, int startIndex)
{
const string arrayPath = ".Array.";
const string arrayDataPath = "data[";
const string arraySizePath = "size";
if (path.Length < startIndex + arrayPath.Length)
return new ArrayResult(null, -1, -1, -1);
var arrayStartIndex = path.IndexOf(arrayPath, startIndex, StringComparison.Ordinal);
if (arrayStartIndex != -1)
{
if (path.Length > arrayStartIndex + arrayPath.Length + arrayDataPath.Length)
{
// Extract data index
var dataStartIndex = path.IndexOf(arrayDataPath, arrayStartIndex + arrayPath.Length, StringComparison.Ordinal);
if (dataStartIndex != -1)
{
dataStartIndex += arrayDataPath.Length; // Go to the end
// Extract the number between [ and ].
var arrayBracketEndIdx = path.IndexOf(']', dataStartIndex);
if (arrayBracketEndIdx != -1)
{
return new ArrayResult(path, arrayStartIndex + 1, dataStartIndex, arrayBracketEndIdx); // +1 so we start at Array and not the '.'
}
}
}
// Is it an array size?
if (path.Length == arrayStartIndex + arraySizePath.Length + arrayPath.Length && path.EndsWith(arraySizePath))
return new ArrayResult(path, arrayStartIndex + 1, -1, -1); // +1 so we start at Array and not the '.'
}
return new ArrayResult(null, -1, -1, -1);
}
internal static JToken GetPropertyFromPath(string path, JContainer obj)
{
var nextTokenStart = 0;
var nextArrayElement = GetNextArrayItem(path, 0);
var parent = obj;
while (nextTokenStart != -1 && nextTokenStart < path.Length)
{
// Is this an array element?
if (nextTokenStart == nextArrayElement.arrayStartIndex)
{
if (!(parent is JArray arrayElement))
{
arrayElement = new JArray();
parent.Add(arrayElement);
}
if (nextArrayElement.IsArraySize)
return arrayElement;
var dataIndex = nextArrayElement.GetDataIndex();
if (dataIndex == -1)
return null;
// If the array is too small then add items until it contains at least enough for the new item.
while (arrayElement.Count <= dataIndex)
{
arrayElement.Add(new JObject());
}
if (nextArrayElement.IsArrayElement)
{
var arrayItem = arrayElement[dataIndex] as JValue;
if (arrayItem == null)
{
arrayItem = new JValue(string.Empty);
arrayElement[dataIndex] = arrayItem;
}
return arrayItem;
}
parent = arrayElement[dataIndex] as JObject;
if (parent == null)
{
parent = new JObject();
arrayElement[dataIndex] = parent;
}
nextTokenStart = nextArrayElement.arrayDataIndexEnd + 2; // "]."
nextArrayElement = GetNextArrayItem(path, nextTokenStart);
}
else
{
var tokenEndIdx = path.IndexOf('.', nextTokenStart);
var token = tokenEndIdx == -1 ? path.Substring(nextTokenStart) : path.Substring(nextTokenStart, tokenEndIdx - nextTokenStart);
// Is this the last token?
if (tokenEndIdx == -1)
{
var property = (JProperty)parent[token]?.Parent;
JValue value;
if (property == null)
{
value = new JValue(string.Empty);
property = new JProperty(token, value);
parent.Add(property);
}
else
{
value = property.Value as JValue;
if (value == null)
{
value = new JValue(string.Empty);
property.Value = value;
}
}
return value;
}
var tokenChild = (JContainer)parent[token];
if (tokenChild == null)
{
tokenChild = new JObject();
parent[token] = tokenChild;
}
parent = tokenChild;
nextTokenStart = tokenEndIdx + 1;
}
}
return null;
}
}
}
#endif