// Animancer // Copyright 2020 Kybernetik //
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditorInternal;
#endif
namespace Animancer
{
/// [Pro-Only]
/// An which blends multiple child states. Unlike other mixers, this class does not
/// perform any automatic weight calculations, it simple allows you to control the weight of all states manually.
///
/// This mixer type is similar to the Direct Blend Type in Mecanim Blend Trees.
///
public class ManualMixerState : MixerState
{
/************************************************************************************************************************/
#region Properties
/************************************************************************************************************************/
/// The states managed by this mixer.
public AnimancerState[] States { get; protected set; }
/// The number of input ports in the .
public override int PortCount { get { return States.Length; } }
/************************************************************************************************************************/
/// Returns the array.
public override IList ChildStates { get { return States; } }
/************************************************************************************************************************/
///
/// The weighted average of each child state according to their
/// .
///
protected override float NewTime
{
get
{
var totalWeight = 0f;
var normalizedTime = 0f;
var length = 0f;
var count = States.Length;
while (--count >= 0)
{
var state = States[count];
if (state != null)
{
var weight = state.Weight;
totalWeight += weight;
normalizedTime += state.NormalizedTime * weight;
length += state.Length * weight;
}
}
if (totalWeight == 0)
return 0;
totalWeight = 1f / totalWeight;
return normalizedTime * totalWeight * length * totalWeight;
}
set
{
var count = States.Length;
if (value == 0)
goto ZeroTime;
var length = Length;
if (length == 0)
goto ZeroTime;
value /= length;// Normalize.
while (--count >= 0)
{
var state = States[count];
if (state != null)
state.NormalizedTime = value;
}
return;
// If the value is 0, we can set the child times slightly more efficiently.
ZeroTime:
while (--count >= 0)
{
var state = States[count];
if (state != null)
state.Time = 0;
}
}
}
/************************************************************************************************************************/
///
/// The weighted average of each child state according to their
/// .
///
public override float Length
{
get
{
var length = 0f;
var childWeight = CalculateTotalChildWeight();
if (childWeight == 0)
return 0;
childWeight = 1f / childWeight;
var count = States.Length;
while (--count >= 0)
{
var state = States[count];
if (state != null)
length += state.Length * state.Weight * childWeight;
}
return length;
}
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
#region Initialisation
/************************************************************************************************************************/
///
/// Constructs a new without connecting it to the .
///
protected ManualMixerState(AnimancerPlayable root) : base(root) { }
///
/// Constructs a new and connects it to the `layer`.
///
public ManualMixerState(AnimancerLayer layer) : base(layer) { }
///
/// Constructs a new and connects it to the `parent` at the specified
/// `index`.
///
public ManualMixerState(AnimancerNode parent, int index) : base(parent, index) { }
/************************************************************************************************************************/
///
/// Initialises this mixer with the specified number of ports which can be filled individually by .
///
public virtual void Initialise(int portCount)
{
if (portCount <= 1)
Debug.LogWarning(GetType() + " is being initialised with capacity <= 1. The purpose of a mixer is to mix multiple clips.");
_Playable.SetInputCount(portCount);
States = new AnimancerState[portCount];
}
/************************************************************************************************************************/
///
/// Initialises this mixer with one state per clip.
///
public void Initialise(params AnimationClip[] clips)
{
int count = clips.Length;
_Playable.SetInputCount(count);
if (count <= 1)
Debug.LogWarning(GetType() + " is being initialised without multiple clips. The purpose of a mixer is to mix multiple clips.");
States = new AnimancerState[count];
for (int i = 0; i < count; i++)
{
var clip = clips[i];
if (clip != null)
new ClipState(this, i, clip);
}
}
/************************************************************************************************************************/
///
/// Creates and returns a new to play the `clip` with this
/// as its parent.
///
public override ClipState CreateState(int index, AnimationClip clip)
{
var oldState = States[index];
if (oldState != null)
{
// If the old state has the specified `clip`, return it.
if (oldState.Clip == clip)
{
return oldState as ClipState;
}
else// Otherwise destroy and replace it.
{
oldState.Destroy();
}
}
return base.CreateState(index, clip);
}
/************************************************************************************************************************/
/// Connects the `state` to this mixer at its .
protected internal override void OnAddChild(AnimancerState state)
{
OnAddChild(States, state);
}
/// Disconnects the `state` from this mixer at its .
protected internal override void OnRemoveChild(AnimancerState state)
{
Validate.RemoveChild(state, States);
States[state.Index] = null;
state.DisconnectFromGraph();
}
/************************************************************************************************************************/
///
/// Destroys the of this mixer and its .
///
public override void Destroy()
{
DestroyChildren();
base.Destroy();
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
#region Child States
/************************************************************************************************************************/
///
/// Calculates the sum of the of all child states.
///
public float CalculateTotalChildWeight()
{
if (States == null)
return 0;
var total = 0f;
var count = States.Length;
while (--count >= 0)
{
var state = States[count];
if (state != null)
total += state.Weight;
}
return total;
}
/************************************************************************************************************************/
///
/// Destroys all connected to this mixer. This operation cannot be undone.
///
public void DestroyChildren()
{
if (States == null)
return;
var count = States.Length;
while (--count >= 0)
{
var state = States[count];
if (state != null)
state.Destroy();
}
States = null;
}
/************************************************************************************************************************/
///
/// Does nothing. Manual mixers do not automatically recalculate their weights.
///
public override void RecalculateWeights() { }
/************************************************************************************************************************/
///
/// Sets the weight of all states after the `previousIndex` to 0.
///
protected void DisableRemainingStates(int previousIndex)
{
var count = States.Length;
while (++previousIndex < count)
{
var state = States[previousIndex];
if (state == null)
continue;
state.Weight = 0;
}
}
/************************************************************************************************************************/
///
/// Returns the state at the specified `index` if it is not null, otherwise increments the index and checks
/// again. Returns null if no state is found by the end of the array.
///
protected AnimancerState GetNextState(ref int index)
{
while (index < States.Length)
{
var state = States[index];
if (state != null)
return state;
index++;
}
return null;
}
/************************************************************************************************************************/
///
/// Divides the weight of all states by the `totalWeight` so that they all add up to 1.
///
protected void NormalizeWeights(float totalWeight)
{
if (totalWeight == 1)
return;
totalWeight = 1f / totalWeight;
int count = States.Length;
for (int i = 0; i < count; i++)
{
var state = States[i];
if (state == null)
continue;
state.Weight *= totalWeight;
}
}
/************************************************************************************************************************/
/// Gets a user-friendly key to identify the `state` in the Inspector.
public override string GetDisplayKey(AnimancerState state)
{
return string.Concat("[", state.Index.ToString(), "]");
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
#region Transition
/************************************************************************************************************************/
///
/// Base class for serializable s which can create a particular type of
/// when passed into .
///
///
/// Unfortunately the tool used to generate this documentation does not currently support nested types with
/// identical names, so only one Transition class will actually have a documentation page.
///
/// Even though it has the , this class won't actually get serialized
/// by Unity because it's generic and abstract. Each child class still needs to include the attribute.
///
[Serializable]
public abstract new class Transition : AnimancerState.Transition, IAnimationClipCollection
where TMixer : ManualMixerState
{
/************************************************************************************************************************/
[SerializeField, HideInInspector]
private AnimationClip[] _Clips;
/// []
/// The to use for each state in the mixer.
///
public AnimationClip[] Clips
{
get { return _Clips; }
set { _Clips = value; }
}
[SerializeField, HideInInspector]
private float[] _Speeds;
/// []
/// The to use for each state in the mixer.
///
/// If the size of this array doesn't match the , it will be ignored.
///
public float[] Speeds
{
get { return _Speeds; }
set { _Speeds = value; }
}
/************************************************************************************************************************/
[SerializeField, HideInInspector]
private bool[] _SynchroniseChildren;
/// []
/// The flags for each state in the mixer.
///
/// The array can be null or empty. Any elements not in the array will be treated as true.
///
public bool[] SynchroniseChildren
{
get { return _SynchroniseChildren; }
set { _SynchroniseChildren = value; }
}
/************************************************************************************************************************/
/// []
/// Returns true is any of the are looping.
///
public override bool IsLooping
{
get
{
var count = _Clips.Length;
for (int i = 0; i < count; i++)
{
var clip = _Clips[i];
if (clip == null)
continue;
if (clip.isLooping)
return true;
}
return false;
}
}
/// []
/// The maximum amount of time the animation is expected to take (in seconds).
///
public override float MaximumDuration
{
get
{
if (_Clips == null)
return 0;
var duration = 0f;
var hasSpeeds = _Speeds != null && _Speeds.Length == _Clips.Length;
for (int i = 0; i < _Clips.Length; i++)
{
var clip = _Clips[i];
if (clip == null)
continue;
var length = clip.length;
if (hasSpeeds)
length *= _Speeds[i];
if (duration < length)
duration = length;
}
return duration;
}
}
/************************************************************************************************************************/
///
/// Initialises the immediately after it is created.
///
public virtual void InitialiseState()
{
State.Initialise(_Clips);
if (_Speeds != null && _Speeds.Length == _Clips.Length)
{
for (int i = 0; i < State.States.Length; i++)
{
State.States[i].Speed = _Speeds[i];
}
}
State.SynchroniseChildren = _SynchroniseChildren;
}
/************************************************************************************************************************/
/// Adds the to the collection.
void IAnimationClipCollection.GatherAnimationClips(ICollection clips)
{
clips.Gather(_Clips);
}
/************************************************************************************************************************/
#if UNITY_EDITOR
/************************************************************************************************************************/
/// [Editor-Only] Adds context menu functions for this transition.
protected override void AddItemsToContextMenu(GenericMenu menu, SerializedProperty property,
Editor.Serialization.PropertyAccessor accessor)
{
base.AddItemsToContextMenu(menu, property, accessor);
Transition.Drawer.AddItemsToContextMenu(menu, property);
}
/************************************************************************************************************************/
#endif
/************************************************************************************************************************/
}
/************************************************************************************************************************/
///
/// A serializable which can create a when
/// passed into .
///
///
/// Unfortunately the tool used to generate this documentation does not currently support nested types with
/// identical names, so only one Transition class will actually have a documentation page.
///
[Serializable]
public class Transition : Transition
{
/************************************************************************************************************************/
///
/// Creates and returns a new connected to the `layer`.
///
/// This method also assigns it as the .
///
public override ManualMixerState CreateState(AnimancerLayer layer)
{
State = new ManualMixerState(layer);
InitialiseState();
return State;
}
/************************************************************************************************************************/
#region Drawer
#if UNITY_EDITOR
/************************************************************************************************************************/
/// [Editor-Only] Draws the Inspector GUI for a .
[CustomPropertyDrawer(typeof(Transition), true)]
public class Drawer : Editor.TransitionDrawer
{
/************************************************************************************************************************/
///
/// The property this drawer is currently drawing.
///
/// Normally each property has its own drawer, but arrays share a single drawer for all elements.
///
public static SerializedProperty CurrentProperty { get; private set; }
/// The field.
public static SerializedProperty CurrentClips { get; private set; }
/// The field.
public static SerializedProperty CurrentSpeeds { get; private set; }
/// The field.
public static SerializedProperty CurrentSynchroniseChildren { get; private set; }
private readonly Dictionary
PropertyPathToStates = new Dictionary();
///
/// Gather the details of the `property`.
///
/// This method gets called by every and call since
/// Unity uses the same instance for each element in a collection, so it
/// needs to gather the details associated with the current property.
///
protected virtual ReorderableList GatherDetails(SerializedProperty property)
{
InitialiseMode(property);
GatherSubProperties(property);
var propertyPath = property.propertyPath;
ReorderableList states;
if (!PropertyPathToStates.TryGetValue(propertyPath, out states))
{
states = new ReorderableList(CurrentClips.serializedObject, CurrentClips)
{
drawHeaderCallback = DoStateListHeaderGUI,
elementHeightCallback = GetElementHeight,
drawElementCallback = DoElementGUI,
onAddCallback = OnAddElement,
onRemoveCallback = OnRemoveElement,
#if UNITY_2018_1_OR_NEWER
onReorderCallbackWithDetails = OnReorderList,
#else
onReorderCallback = OnReorderList,
onSelectCallback = OnListSelectionChanged,
#endif
};
PropertyPathToStates.Add(propertyPath, states);
}
return states;
}
/************************************************************************************************************************/
///
/// Called every time a `property` is drawn to find the relevant child properties and store them to be
/// used in and .
///
protected virtual void GatherSubProperties(SerializedProperty property)
{
GatherSubPropertiesStatic(property);
}
///
/// Called every time a `property` is drawn to find the relevant child properties and store them to be
/// used in and .
///
public static void GatherSubPropertiesStatic(SerializedProperty property)
{
CurrentProperty = property;
CurrentClips = property.FindPropertyRelative("_Clips");
CurrentSpeeds = property.FindPropertyRelative("_Speeds");
CurrentSynchroniseChildren = property.FindPropertyRelative("_SynchroniseChildren");
if (CurrentSpeeds.arraySize != 0)
CurrentSpeeds.arraySize = CurrentClips.arraySize;
}
/************************************************************************************************************************/
///
/// Calculates the number of vertical pixels the `property` will occupy when it is drawn.
///
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
var height = EditorGUI.GetPropertyHeight(property, label);
if (property.isExpanded)
{
var states = GatherDetails(property);
height += Editor.AnimancerGUI.StandardSpacing + states.GetHeight();
}
return height;
}
/************************************************************************************************************************/
///
/// Draws the root `property` GUI and calls
/// for each of its children.
///
public override void OnGUI(Rect area, SerializedProperty property, GUIContent label)
{
var originalProperty = property.Copy();
base.OnGUI(area, property, label);
if (!originalProperty.isExpanded)
return;
var states = GatherDetails(originalProperty);
var indentLevel = EditorGUI.indentLevel;
area.yMin = area.yMax - states.GetHeight();
EditorGUI.indentLevel++;
area = EditorGUI.IndentedRect(area);
EditorGUI.indentLevel = 0;
states.DoList(area);
EditorGUI.indentLevel = indentLevel;
TryCollapseSpeeds();
}
/************************************************************************************************************************/
/// Splits the specified `area` into separate sections.
protected void SplitListRect(Rect area, out Rect animation, out Rect speed, out Rect sync)
{
var spacing = Editor.AnimancerGUI.StandardSpacing;
area.width += 2;
sync = Editor.AnimancerGUI.StealFromRight(ref area, Editor.AnimancerGUI.ToggleWidth - spacing, spacing);
speed = Editor.AnimancerGUI.StealFromRight(ref area, 55, spacing);
animation = area;
}
/************************************************************************************************************************/
/// Draws the headdings of the state list.
protected virtual void DoStateListHeaderGUI(Rect area)
{
Rect animationArea, speedArea, syncArea;
SplitListRect(area, out animationArea, out speedArea, out syncArea);
DoAnimationLabelGUI(animationArea);
DoSpeedLabelGUI(speedArea);
DoSyncLabelGUI(syncArea);
}
/************************************************************************************************************************/
/// Draws an "Animation" label.
protected static void DoAnimationLabelGUI(Rect area)
{
EditorGUI.BeginProperty(area, GUIContent.none, CurrentClips);
GUI.Label(area, Editor.AnimancerGUI.TempContent("Animation",
"The animations that will be used for each child state"));
EditorGUI.EndProperty();
}
/// Draws a "Speed" label.
protected static void DoSpeedLabelGUI(Rect area)
{
EditorGUI.BeginProperty(area, GUIContent.none, CurrentSpeeds);
GUI.Label(area, Editor.AnimancerGUI.TempContent("Speed",
"Determines how fast each child state plays (Default = 1)"));
EditorGUI.EndProperty();
}
private static float _SyncLabelWidth;
/// Draws a "Sync" label.
protected static void DoSyncLabelGUI(Rect area)
{
const string Text = "Sync";
if (_SyncLabelWidth == 0)
_SyncLabelWidth = Editor.AnimancerGUI.CalculateLabelWidth(Text);
if (area.width < _SyncLabelWidth)
area.xMin = area.xMax - _SyncLabelWidth;
EditorGUI.BeginProperty(area, GUIContent.none, CurrentSynchroniseChildren);
GUI.Label(area, Editor.AnimancerGUI.TempContent(Text,
"Determines which child states have their normalized times constantly synchronised"));
EditorGUI.EndProperty();
}
/************************************************************************************************************************/
/// Calculates the height of the state at the specified `index`.
protected virtual float GetElementHeight(int index)
{
return Editor.AnimancerGUI.LineHeight;
}
/************************************************************************************************************************/
/// Draws the GUI of the state at the specified `index`.
private void DoElementGUI(Rect area, int index, bool isActive, bool isFocused)
{
if (index < 0 || index > CurrentClips.arraySize)
return;
var clip = CurrentClips.GetArrayElementAtIndex(index);
var speed = CurrentSpeeds.arraySize > 0 ? CurrentSpeeds.GetArrayElementAtIndex(index) : null;
DoElementGUI(area, index, clip, speed);
}
/************************************************************************************************************************/
/// Draws the GUI of the state at the specified `index`.
protected virtual void DoElementGUI(Rect area, int index,
SerializedProperty clip, SerializedProperty speed)
{
Rect animationArea, speedArea, syncArea;
SplitListRect(area, out animationArea, out speedArea, out syncArea);
DoElementGUI(animationArea, speedArea, syncArea, index, clip, speed);
}
/// Draws the GUI of the state at the specified `index`.
protected void DoElementGUI(Rect animationArea, Rect speedArea, Rect syncArea, int index,
SerializedProperty clip, SerializedProperty speed)
{
EditorGUI.PropertyField(animationArea, clip, GUIContent.none);
if (speed != null)
{
EditorGUI.PropertyField(speedArea, speed, GUIContent.none);
}
else
{
EditorGUI.BeginProperty(speedArea, GUIContent.none, CurrentSpeeds);
var value = EditorGUI.FloatField(speedArea, 1);
if (value != 1)
{
CurrentSpeeds.InsertArrayElementAtIndex(0);
CurrentSpeeds.GetArrayElementAtIndex(0).floatValue = 1;
CurrentSpeeds.arraySize = CurrentClips.arraySize;
CurrentSpeeds.GetArrayElementAtIndex(index).floatValue = value;
}
EditorGUI.EndProperty();
}
DoSyncToggleGUI(syncArea, index);
}
/************************************************************************************************************************/
///
/// Draws a toggle to enable or disable for the child at
/// the specified `index`.
///
protected void DoSyncToggleGUI(Rect area, int index)
{
var syncFlagCount = CurrentSynchroniseChildren.arraySize;
var property = CurrentSynchroniseChildren;
var enabled = true;
if (index < syncFlagCount)
{
property = property.GetArrayElementAtIndex(index);
enabled = property.boolValue;
}
EditorGUI.BeginChangeCheck();
EditorGUI.BeginProperty(area, GUIContent.none, property);
enabled = GUI.Toggle(area, enabled, GUIContent.none);
EditorGUI.EndProperty();
if (EditorGUI.EndChangeCheck())
{
if (index < syncFlagCount)
{
property.boolValue = enabled;
for (int i = syncFlagCount - 1; i >= 0; i--)
{
if (CurrentSynchroniseChildren.GetArrayElementAtIndex(i).boolValue)
syncFlagCount = i;
else
break;
}
CurrentSynchroniseChildren.arraySize = syncFlagCount;
}
else
{
property.arraySize = index + 1;
for (int i = syncFlagCount; i < index; i++)
{
property.GetArrayElementAtIndex(i).boolValue = true;
}
property.GetArrayElementAtIndex(index).boolValue = enabled;
}
}
}
/************************************************************************************************************************/
///
/// Called when adding a new state to the list to ensure that any other relevant arrays have new
/// elements added as well.
///
protected virtual void OnAddElement(ReorderableList list)
{
var index = CurrentClips.arraySize;
CurrentClips.InsertArrayElementAtIndex(index);
if (CurrentSpeeds.arraySize > 0)
CurrentSpeeds.InsertArrayElementAtIndex(index);
}
/************************************************************************************************************************/
///
/// Called when removing a state from the list to ensure that any other relevant arrays have elements
/// removed as well.
///
protected virtual void OnRemoveElement(ReorderableList list)
{
var index = list.index;
RemoveArrayElement(CurrentClips, index);
if (CurrentSpeeds.arraySize > 0)
RemoveArrayElement(CurrentSpeeds, index);
}
///
/// Removes the specified array element from the `property`.
///
/// If the element is not at its default value, the first call to
/// will only reset it, so this method will
/// call it again if necessary to ensure that it actually gets removed.
///
protected static void RemoveArrayElement(SerializedProperty property, int index)
{
var count = property.arraySize;
property.DeleteArrayElementAtIndex(index);
if (property.arraySize == count)
property.DeleteArrayElementAtIndex(index);
}
/************************************************************************************************************************/
///
/// Called when reordering states in the list to ensure that any other relevant arrays have their
/// corresponding elements reordered as well.
///
protected virtual void OnReorderList(ReorderableList list, int oldIndex, int newIndex)
{
CurrentSpeeds.MoveArrayElement(oldIndex, newIndex);
}
#if !UNITY_2018_1_OR_NEWER
private int _SelectedIndex;
private void OnListSelectionChanged(ReorderableList list)
{
_SelectedIndex = list.index;
}
private void OnReorderList(ReorderableList list)
{
OnReorderList(list, _SelectedIndex, list.index);
}
#endif
/************************************************************************************************************************/
#region Speeds
/************************************************************************************************************************/
///
/// Initialises every element in the array from the `start` to the end of
/// the array to contain a value of 1.
///
public static void InitialiseSpeeds(int start)
{
var count = CurrentSpeeds.arraySize;
while (start < count)
CurrentSpeeds.GetArrayElementAtIndex(start++).floatValue = 1;
}
/************************************************************************************************************************/
///
/// If every element in the array is 1, this method sets the array size to 0.
///
public static void TryCollapseSpeeds()
{
var speedCount = CurrentSpeeds.arraySize;
if (speedCount <= 0)
return;
for (int i = 0; i < speedCount; i++)
{
if (CurrentSpeeds.GetArrayElementAtIndex(i).floatValue != 1)
return;
}
CurrentSpeeds.arraySize = 0;
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
#region Context Menu
/************************************************************************************************************************/
/// [Editor-Only] Adds context menu functions for this transition.
public static void AddItemsToContextMenu(GenericMenu menu, SerializedProperty property)
{
GatherSubPropertiesStatic(property);
AddPropertyModifierFunction(menu, "Reset Speeds", (_) => CurrentSpeeds.arraySize = 0);
AddPropertyModifierFunction(menu, "Normalize Durations", NormalizeDurations);
}
/************************************************************************************************************************/
///
/// Recalculates the depending on the of
/// their animations so that they all take the same amount of time to play fully.
///
private static void NormalizeDurations(SerializedProperty property)
{
var speedCount = CurrentSpeeds.arraySize;
var lengths = new float[CurrentClips.arraySize];
if (lengths.Length <= 1)
return;
int nonZeroLengths = 0;
float totalLength = 0;
float totalSpeed = 0;
for (int i = 0; i < lengths.Length; i++)
{
var clip = CurrentClips.GetArrayElementAtIndex(i).objectReferenceValue as AnimationClip;
if (clip != null && clip.length > 0)
{
nonZeroLengths++;
totalLength += clip.length;
lengths[i] = clip.length;
if (speedCount > 0)
totalSpeed += CurrentSpeeds.GetArrayElementAtIndex(i).floatValue;
}
}
if (nonZeroLengths == 0)
return;
var averageLength = totalLength / nonZeroLengths;
var averageSpeed = speedCount > 0 ? totalSpeed / nonZeroLengths : 1;
CurrentSpeeds.arraySize = lengths.Length;
InitialiseSpeeds(speedCount);
for (int i = 0; i < lengths.Length; i++)
{
if (lengths[i] == 0)
continue;
CurrentSpeeds.GetArrayElementAtIndex(i).floatValue = averageSpeed * lengths[i] / averageLength;
}
TryCollapseSpeeds();
}
/************************************************************************************************************************/
///
/// Adds a menu function that will call then perform the specified
/// `action`.
///
protected static void AddPropertyModifierFunction(GenericMenu menu, string label, Action action)
{
Editor.Serialization.AddPropertyModifierFunction(menu, CurrentProperty, label, (property) =>
{
GatherSubPropertiesStatic(property);
action(property);
});
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
}
/************************************************************************************************************************/
#endif
#endregion
/************************************************************************************************************************/
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
}
}