From 76edc43acaa72d357e87a8bed4904257a24d6511 Mon Sep 17 00:00:00 2001 From: bd_ Date: Sat, 4 Mar 2023 14:31:23 +0900 Subject: [PATCH] feat: menu actions (#231) Add a new feature to generate animations for menu items automatically. Currently this only supports generating blend-tree based GameObject toggles. --- .../Editor/AvatarProcessor.cs | 159 ++++--- .../Editor/ErrorReporting/ErrorLog.cs | 5 +- .../Editor/Inspector/MAEditorBase.cs | 16 +- .../Menu/ActionToggleObjectInspector.cs | 173 +++++++ .../Menu/ActionToggleObjectInspector.cs.meta | 3 + .../Editor/Inspector/Menu/AvMenuTreeView.cs | 6 +- .../Inspector/Menu/ControlGroupInspector.cs | 114 +++++ .../Menu/ControlGroupInspector.cs.meta | 3 + .../Editor/Inspector/Menu/MenuItemGUI.cs | 120 ++++- .../Editor/Inspector/Menu/MenuPreviewGUI.cs | 156 ++++--- .../Editor/Inspector/Menu/ParameterField.cs | 17 +- .../Editor/Localization/en.json | 12 +- .../Editor/Localization/ja.json | 12 +- .../Editor/Menu/ActionProcessing.meta | 4 + .../Menu/ActionProcessing/ActionGenerator.cs | 430 ++++++++++++++++++ .../ActionProcessing/ActionGenerator.cs.meta | 3 + .../Runtime/Menu/Actions.meta | 4 + .../Menu/Actions/ActionToggleObject.cs | 68 +++ .../Menu/Actions/ActionToggleObject.cs.meta | 3 + .../Runtime/Menu/Actions/ControlGroup.cs | 9 + .../Runtime/Menu/Actions/ControlGroup.cs.meta | 3 + .../Runtime/Menu/Actions/MenuAction.cs | 102 +++++ .../Runtime/Menu/Actions/MenuAction.cs.meta | 3 + .../Runtime/ModularAvatarMenuItem.cs | 4 + 24 files changed, 1269 insertions(+), 160 deletions(-) create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta diff --git a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs index 66cc997d..e5cde52b 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs @@ -23,8 +23,10 @@ */ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using nadena.dev.modular_avatar.editor.ErrorReporting; @@ -147,77 +149,120 @@ namespace nadena.dev.modular_avatar.core.editor { try { - AssetDatabase.StartAssetEditing(); - nowProcessing = true; - - ClearEditorOnlyTagComponents(avatarGameObject.transform); - - BoneDatabase.ResetBones(); - PathMappings.Init(vrcAvatarDescriptor.gameObject); - ClonedMenuMappings.Clear(); - - // Sometimes people like to nest one avatar in another, when transplanting clothing. To avoid issues - // with inconsistently determining the avatar root, we'll go ahead and remove the extra sub-avatars - // here. - foreach (Transform directChild in avatarGameObject.transform) + try { - foreach (var component in directChild.GetComponentsInChildren(true)) + AssetDatabase.StartAssetEditing(); + nowProcessing = true; + + ClearEditorOnlyTagComponents(avatarGameObject.transform); + + BoneDatabase.ResetBones(); + PathMappings.Init(vrcAvatarDescriptor.gameObject); + ClonedMenuMappings.Clear(); + + // Sometimes people like to nest one avatar in another, when transplanting clothing. To avoid issues + // with inconsistently determining the avatar root, we'll go ahead and remove the extra sub-avatars + // here. + foreach (Transform directChild in avatarGameObject.transform) { - Object.DestroyImmediate(component); + foreach (var component in directChild.GetComponentsInChildren(true)) + { + Object.DestroyImmediate(component); + } + + foreach (var component in directChild.GetComponentsInChildren(true)) + { + Object.DestroyImmediate(component); + } } - foreach (var component in directChild.GetComponentsInChildren(true)) - { - Object.DestroyImmediate(component); - } + var context = new BuildContext(vrcAvatarDescriptor); + + new ActionGenerator(context).OnPreprocessAvatar(vrcAvatarDescriptor); + new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context); + new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context); + context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor); + + new MenuInstallHook().OnPreprocessAvatar(avatarGameObject, context); + new MergeArmatureHook().OnPreprocessAvatar(context, avatarGameObject); + new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject); + new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context); + new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase); + new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context); + PhysboneBlockerPass.Process(avatarGameObject); + + context.AnimationDatabase.Commit(); + + new GCGameObjectsPass(context, avatarGameObject).OnPreprocessAvatar(); + + AfterProcessing?.Invoke(avatarGameObject); } + finally + { + AssetDatabase.StopAssetEditing(); - var context = new BuildContext(vrcAvatarDescriptor); + nowProcessing = false; - new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context); - new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context); - context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor); + // Ensure that we clean up AvatarTagComponents after failed processing. This ensures we don't re-enter + // processing from the Awake method on the unprocessed AvatarTagComponents + var toDestroy = avatarGameObject.GetComponentsInChildren(true).ToList(); + var retryDestroy = new List(); - new MenuInstallHook().OnPreprocessAvatar(avatarGameObject, context); - new MergeArmatureHook().OnPreprocessAvatar(context, avatarGameObject); - new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject); - new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context); - new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase); - new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context); - PhysboneBlockerPass.Process(avatarGameObject); + // Sometimes AvatarTagComponents have interdependencies and need to be deleted in the right order; + // retry until we purge them all. + bool madeProgress = true; + while (toDestroy.Count > 0) + { + if (!madeProgress) + { + throw new Exception("One or more components failed to destroy." + + RuntimeUtil.AvatarRootPath(toDestroy[0].gameObject)); + } - context.AnimationDatabase.Commit(); + foreach (var component in toDestroy) + { + try + { + if (component != null) + { + UnityEngine.Object.DestroyImmediate(component); + madeProgress = true; + } + } + catch (Exception e) + { + retryDestroy.Add(component); + } + } - new GCGameObjectsPass(context, avatarGameObject).OnPreprocessAvatar(); + toDestroy = retryDestroy; + retryDestroy = new List(); + } - AfterProcessing?.Invoke(avatarGameObject); + var activator = avatarGameObject.GetComponent(); + if (activator != null) + { + UnityEngine.Object.DestroyImmediate(activator); + } + + ClonedMenuMappings.Clear(); + + ErrorReportUI.MaybeOpenErrorReportUI(); + + AssetDatabase.SaveAssets(); + + Resources.UnloadUnusedAssets(); + } } - finally + catch (Exception e) { - AssetDatabase.StopAssetEditing(); + BuildReport.LogException(e); + throw; + } - nowProcessing = false; - - // Ensure that we clean up AvatarTagComponents after failed processing. This ensures we don't re-enter - // processing from the Awake method on the unprocessed AvatarTagComponents - foreach (var component in avatarGameObject.GetComponentsInChildren(true)) - { - UnityEngine.Object.DestroyImmediate(component); - } - - var activator = avatarGameObject.GetComponent(); - if (activator != null) - { - UnityEngine.Object.DestroyImmediate(activator); - } - - ClonedMenuMappings.Clear(); - - ErrorReportUI.MaybeOpenErrorReportUI(); - - AssetDatabase.SaveAssets(); - - Resources.UnloadUnusedAssets(); + if (!BuildReport.CurrentReport.CurrentAvatar.successful) + { + throw new Exception("Fatal error reported during avatar processing."); } } } diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs index 26b98951..5aa74499 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs @@ -293,7 +293,8 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting internal static void Log(ReportLevel level, string code, object[] strings, params Object[] objects) { - ErrorLog errorLog = new ErrorLog(level, code, strings, objects); + ErrorLog errorLog = + new ErrorLog(level, code, strings: strings.Select(s => s.ToString()).ToArray(), objects); var avatarReport = CurrentReport._currentAvatar; if (avatarReport == null) @@ -307,7 +308,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting internal static void LogFatal(string code, object[] strings, params Object[] objects) { - Log(ReportLevel.Error, code, strings, objects); + Log(ReportLevel.Error, code, strings: strings, objects: objects); if (CurrentReport._currentAvatar != null) { CurrentReport._currentAvatar.successful = false; diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs index 3d9cd96e..b962b83d 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs @@ -81,10 +81,7 @@ namespace nadena.dev.modular_avatar.core.editor protected virtual VisualElement CreateInnerInspectorGUI() { - var throwaway = new InspectorElement(); - MethodInfo m = typeof(InspectorElement).GetMethod("CreateIMGUIInspectorFromEditor", - BindingFlags.NonPublic | BindingFlags.Instance); - return m.Invoke(throwaway, new object[] {serializedObject, this, false}) as VisualElement; + return null; } public sealed override VisualElement CreateInspectorGUI() @@ -94,10 +91,19 @@ namespace nadena.dev.modular_avatar.core.editor var inner = CreateInnerInspectorGUI(); + bool innerIsImgui = (inner == null); + if (innerIsImgui) + { + var throwaway = new InspectorElement(); + MethodInfo m = typeof(InspectorElement).GetMethod("CreateIMGUIInspectorFromEditor", + BindingFlags.NonPublic | BindingFlags.Instance); + inner = m.Invoke(throwaway, new object[] {serializedObject, this, false}) as VisualElement; + } + _visualElement = new MAVisualElement(); _visualElement.Add(inner); - _suppressOnceDefaultMargins = true; + _suppressOnceDefaultMargins = innerIsImgui; return _visualElement; } diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs new file mode 100644 index 00000000..985f32c4 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; +using static nadena.dev.modular_avatar.core.editor.Localization; + +namespace nadena.dev.modular_avatar.core.editor +{ + [CustomEditor(typeof(ActionToggleObject))] + internal class ActionToggleObjectInspector : MAEditorBase + { + private ReorderableList _list; + private SerializedProperty _listProp; + + private void OnEnable() + { + _listProp = serializedObject.FindProperty(nameof(ActionToggleObject.Objects)); + _list = new ReorderableList( + serializedObject, + _listProp, + true, + true, + true, + true + ); + + _list.drawHeaderCallback += DrawListHeader; + _list.drawElementCallback += DrawElement; + _list.onRemoveCallback += OnRemoveElement; + _list.onAddCallback += OnAddElement; + + _list.elementHeight += 2; + } + + private void OnAddElement(ReorderableList list) + { + _listProp.arraySize++; + } + + private void OnRemoveElement(ReorderableList reorderableList) + { + if (reorderableList.index < _listProp.arraySize) + { + _listProp.DeleteArrayElementAtIndex(reorderableList.index); + } + } + + private Rect _checkRect, _objectRect; + + private void DrawListHeader(Rect rect) + { + var margin = 2; + + var t = EditorStyles.toggle; + var toggleSize = t.CalcSize(GUIContent.none); + _checkRect = new Rect(0, 0, toggleSize.x, toggleSize.y); + _checkRect.y += (rect.height - _checkRect.height); + + _objectRect = new Rect(_checkRect.xMax + margin, 0, rect.width - _checkRect.width - margin, + _list.elementHeight); + + EditorGUI.LabelField(rect, G("action.toggle_object.header")); + } + + private void DrawElement(Rect rect, int index, bool isactive, bool isfocused) + { + var element = _listProp.GetArrayElementAtIndex(index); + var activeProp = element.FindPropertyRelative(nameof(ActionToggleObject.ObjectEntry.Active)); + var targetProp = element.FindPropertyRelative(nameof(ActionToggleObject.ObjectEntry.target)); + + var checkRect = _checkRect; + checkRect.x += rect.x; + checkRect.y += rect.y; + + var objectRect = _objectRect; + objectRect.x += rect.x; + objectRect.y += rect.y; + objectRect.xMax = rect.xMax; + objectRect.yMin += 1; + objectRect.yMax -= 1; + + using (new ZeroIndentScope()) + { + EditorGUI.PropertyField(checkRect, activeProp, GUIContent.none); + EditorGUI.PropertyField(objectRect, targetProp, GUIContent.none); + } + } + + + protected override void OnInnerInspectorGUI() + { + serializedObject.Update(); + + Rect rect = GUILayoutUtility.GetRect( + 10f, + _list.headerHeight + _list.elementHeight * Math.Max(1, _list.serializedProperty.arraySize) + + _list.footerHeight, + GUILayout.ExpandWidth(true) + ); + + _list.DoList(rect); + + EditorGUILayout.Space(8); + + Localization.ShowLanguageUI(); + + serializedObject.ApplyModifiedProperties(); + + if (rect.Contains(Event.current.mousePosition)) + { + switch (Event.current.type) + { + case EventType.DragUpdated: + if (!DragIsGameObject()) break; + DragAndDrop.visualMode = DragAndDropVisualMode.Link; + Event.current.Use(); + break; + case EventType.DragPerform: + { + if (!DragIsGameObject()) break; + var targetObj = (ActionToggleObject) target; + + if (targetObj.Objects == null) + { + targetObj.Objects = new List(); + } + + HashSet currentObjects = new HashSet(); + foreach (var obj in targetObj.Objects) + { + if (obj != null && obj.target != null) + { + currentObjects.Add(obj.target); + } + } + + var objects = targetObj.Objects.ToList(); + + foreach (var obj in DragAndDrop.objectReferences) + { + if (obj is GameObject go && !currentObjects.Contains(go)) + { + objects.Add(new ActionToggleObject.ObjectEntry() + { + target = go, + Active = go.activeSelf + }); + } + } + + Undo.RecordObject(targetObj, "Add objects"); + targetObj.Objects = objects; + EditorUtility.SetDirty(targetObj); + PrefabUtility.RecordPrefabInstancePropertyModifications(targetObj); + break; + } + } + } + } + + private bool DragIsGameObject() + { + foreach (var obj in DragAndDrop.objectReferences) + { + if (obj is GameObject) return true; + } + + return false; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta new file mode 100644 index 00000000..786c7d10 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: df175549a5ea45d59d9f2daa031bbbf1 +timeCreated: 1677901132 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs index b37edadc..b4171692 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs @@ -4,13 +4,13 @@ using System.Collections.Immutable; using System.Linq; using nadena.dev.modular_avatar.core.editor.menu; using nadena.dev.modular_avatar.core.menu; -using NUnit.Framework; using UnityEditor; using UnityEditor.IMGUI.Controls; using UnityEngine; using VRC.SDK3.Avatars.Components; using VRC.SDK3.Avatars.ScriptableObjects; -using VRC.SDKBase; +using static nadena.dev.modular_avatar.core.editor.Localization; + namespace nadena.dev.modular_avatar.core.editor { @@ -73,7 +73,7 @@ namespace nadena.dev.modular_avatar.core.editor Action OnSelect) { var window = GetWindow(); - window.titleContent = new GUIContent("Select menu"); + window.titleContent = G("menu_tree.title"); window.Avatar = Avatar; window.TargetInstaller = Installer; diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs new file mode 100644 index 00000000..0968d6be --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using static nadena.dev.modular_avatar.core.editor.Localization; + +namespace nadena.dev.modular_avatar.core.editor +{ + [CustomEditor(typeof(ControlGroup))] + internal class ControlGroupInspector : MAEditorBase + { + private bool _showInner; + + private void OnEnable() + { + EditorApplication.hierarchyChanged += Invalidate; + } + + private void OnDisable() + { + EditorApplication.hierarchyChanged -= Invalidate; + } + + private List _menuItemActions = null; + + private void Invalidate() + { + var target = (ControlGroup) this.target; + var avatar = RuntimeUtil.FindAvatarInParents(target.transform); + var menuItems = avatar.GetComponentsInChildren(true); + + _menuItemActions = new List(); + foreach (var menuItem in menuItems.Where(item => item.controlGroup == target)) + { + var node = CreateMenuItemNode(menuItem); + _menuItemActions.Add(node); + } + } + + private Action CreateMenuItemNode(ModularAvatarMenuItem menuItem) + { + bool foldout = false; + + var coreUI = new MenuItemCoreGUI(new SerializedObject(menuItem), Repaint); + var enableMenuItem = new SerializedObject(menuItem.gameObject).FindProperty("m_IsActive"); + + List foldoutInspectors = null; + + return () => + { + using (new MenuObjectHeader(menuItem, enableMenuItem).Scope()) + { + coreUI.DoGUI(); + + foldout = EditorGUILayout.Foldout(foldout, G("control_group.foldout.actions")); + if (foldout) + { + if (foldoutInspectors == null) + { + foldoutInspectors = menuItem.GetComponents() + .Select(action => + { + var component = (Component) action; + var editor = CreateEditor(component); + var enabled_prop = new SerializedObject(component).FindProperty("m_Enabled"); + + return (Action) (() => + { + using (new MenuObjectHeader(component, enabled_prop).Scope()) + { + editor.OnInspectorGUI(); + } + }); + }) + .ToList(); + } + + foreach (var inspector in foldoutInspectors) + { + inspector(); + } + } + } + }; + } + + protected override void OnInnerInspectorGUI() + { + if (_menuItemActions == null) Invalidate(); + + _showInner = EditorGUILayout.Foldout(_showInner, G("control_group.foldout.menu_items")); + if (_showInner) + { + foreach (var action in _menuItemActions) + { + try + { + EditorGUI.indentLevel++; + action(); + } + finally + { + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(4); + } + } + + Localization.ShowLanguageUI(); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta new file mode 100644 index 00000000..8fbebf1d --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 357527439f084cd6812d6e5dcd9692f8 +timeCreated: 1677595893 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs index 897b7824..ce3d6d44 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs @@ -1,10 +1,10 @@ using System; using System.Linq; using System.Runtime.Serialization; -using Codice.CM.Common.Tree.Partial; using nadena.dev.modular_avatar.core.menu; using UnityEditor; using UnityEngine; +using UnityEngine.PlayerLoop; using VRC.SDK3.Avatars.ScriptableObjects; using static nadena.dev.modular_avatar.core.editor.Localization; @@ -29,6 +29,8 @@ namespace nadena.dev.modular_avatar.core.editor private readonly SerializedProperty _type; private readonly SerializedProperty _value; private readonly SerializedProperty _submenu; + private readonly SerializedProperty _controlGroup; + private readonly SerializedProperty _isDefault; private readonly ParameterGUI _parameterGUI; @@ -48,6 +50,27 @@ namespace nadena.dev.modular_avatar.core.editor public bool AlwaysExpandContents = false; public bool ExpandContents = false; + private bool _hasActions; + + private bool HasActions(TargetParameter? p = null) + { + return _controlGroup != null && _obj.targetObjects.Any(o => + { + if (!(o is ModularAvatarMenuItem c)) return false; + if (p.HasValue) + { + foreach (var component in c.GetComponents()) + { + if (component.BindsParameter(p.Value)) return true; + } + + return false; + } + + return true; + }); + } + public MenuItemCoreGUI(SerializedObject obj, Action redraw) { _obj = obj; @@ -68,6 +91,9 @@ namespace nadena.dev.modular_avatar.core.editor ); _name = gameObjects.FindProperty("m_Name"); + _controlGroup = obj.FindProperty(nameof(ModularAvatarMenuItem.controlGroup)); + _isDefault = obj.FindProperty(nameof(ModularAvatarMenuItem.isDefault)); + var control = obj.FindProperty(nameof(ModularAvatarMenuItem.Control)); _texture = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.icon)); @@ -87,6 +113,12 @@ namespace nadena.dev.modular_avatar.core.editor _previewGUI = new MenuPreviewGUI(redraw); } + /// + /// Builds a menu item GUI for a raw VRCExpressionsMenu.Control reference. + /// + /// + /// + /// public MenuItemCoreGUI(GameObject parameterReference, SerializedProperty _control, Action redraw) { _obj = _control.serializedObject; @@ -100,6 +132,9 @@ namespace nadena.dev.modular_avatar.core.editor _value = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.value)); _submenu = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subMenu)); + _controlGroup = null; + _isDefault = null; + _parameterGUI = new ParameterGUI(parameterReference, parameter, redraw); _subParamsRoot = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subParameters)); @@ -112,15 +147,39 @@ namespace nadena.dev.modular_avatar.core.editor public void DoGUI() { + _hasActions = HasActions(); + EditorGUILayout.BeginHorizontal(); EditorGUILayout.BeginVertical(); + EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(_name, G("menuitem.prop.name")); + if (EditorGUI.EndChangeCheck()) + { + _name.serializedObject.ApplyModifiedProperties(); + } + EditorGUILayout.PropertyField(_texture, G("menuitem.prop.icon")); EditorGUILayout.PropertyField(_type, G("menuitem.prop.type")); - EditorGUILayout.PropertyField(_value, G("menuitem.prop.value")); - _parameterGUI.DoGUI(); + if (_hasActions) + { + EditorGUILayout.PropertyField(_controlGroup, G("menuitem.prop.control_group")); + if (_controlGroup.hasMultipleDifferentValues || _controlGroup.objectReferenceValue != null) + { + using (new EditorGUI.DisabledScope(_obj.isEditingMultipleObjects)) + { + EditorGUILayout.PropertyField(_isDefault, G("menuitem.prop.is_default")); + } + } + } + + if (!_hasActions || !HasActions(TargetParameter.BaseParameter)) + { + EditorGUILayout.PropertyField(_value, G("menuitem.prop.value")); + } + + _parameterGUI.DoGUI(!_hasActions || !HasActions(TargetParameter.BaseParameter)); EditorGUILayout.EndVertical(); @@ -172,6 +231,7 @@ namespace nadena.dev.modular_avatar.core.editor case VRCExpressionsMenu.Control.ControlType.SubMenu: { object menuSource = null; + bool canExpand = false; if (_prop_submenuSource != null) { @@ -204,6 +264,8 @@ namespace nadena.dev.modular_avatar.core.editor case SubmenuSource.MenuAsset: { EditorGUILayout.PropertyField(_submenu, G("menuitem.prop.submenu_asset")); + canExpand = true; + if (_submenu.hasMultipleDifferentValues) break; menuSource = _submenu.objectReferenceValue; break; @@ -238,13 +300,43 @@ namespace nadena.dev.modular_avatar.core.editor } } + if (canExpand && (_submenu.hasMultipleDifferentValues || _submenu.objectReferenceValue != null)) + { + if (GUILayout.Button(G("menuitem.misc.extract"))) + { + _obj.ApplyModifiedProperties(); + + foreach (var targetObj in _obj.targetObjects) + { + var menuItem = (ModularAvatarMenuItem) targetObj; + if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu + && menuItem.Control.subMenu != null + && menuItem.MenuSource == SubmenuSource.MenuAsset + ) + { + Undo.RecordObject(menuItem, "Extract menu"); + MenuExtractor.ExtractSingleLayerMenu(menuItem.Control.subMenu, + menuItem.gameObject); + menuItem.Control.subMenu = null; + menuItem.MenuSource = SubmenuSource.Children; + menuItem.menuSource_otherObjectChildren = null; + EditorUtility.SetDirty(menuItem); + PrefabUtility.RecordPrefabInstancePropertyModifications(menuItem); + } + } + + _obj.Update(); + } + } + break; } case VRCExpressionsMenu.Control.ControlType.RadialPuppet: { EnsureParameterCount(1); - _subParams[0].DoGUI(G("menuitem.param.rotation")); + _subParams[0].DoGUI(!_hasActions || !HasActions(TargetParameter.RadialParam), + G("menuitem.param.rotation")); break; } @@ -253,11 +345,13 @@ namespace nadena.dev.modular_avatar.core.editor EnsureParameterCount(2); EnsureLabelCount(4); - EditorGUILayout.LabelField("Parameters", EditorStyles.boldLabel); + EditorGUILayout.LabelField(G("menuitem.label.parameters"), EditorStyles.boldLabel); EditorGUILayout.Space(2); - _subParams[0].DoGUI(G("menuitem.param.horizontal")); - _subParams[1].DoGUI(G("menuitem.param.vertical")); + _subParams[0].DoGUI(!_hasActions || !HasActions(TargetParameter.Horizontal), + G("menuitem.param.horizontal")); + _subParams[1].DoGUI(!_hasActions || !HasActions(TargetParameter.Vertical), + G("menuitem.param.vertical")); DoFourAxisLabels(false); @@ -354,10 +448,10 @@ namespace nadena.dev.modular_avatar.core.editor center.xMin += blockWidth; center.xMax -= blockWidth; - SingleLabel(0, up); - SingleLabel(1, right); - SingleLabel(2, down); - SingleLabel(3, left); + SingleLabel(0, up, TargetParameter.Up); + SingleLabel(1, right, TargetParameter.Right); + SingleLabel(2, down, TargetParameter.Down); + SingleLabel(3, left, TargetParameter.Left); var rect_param_l = center; rect_param_l.yMin = rect_param_l.yMax - EditorGUIUtility.singleLineHeight; @@ -367,7 +461,7 @@ namespace nadena.dev.modular_avatar.core.editor if (showParams) CenterLabel(rect_param_l, G("menuitem.prop.parameter"), EditorStyles.label); CenterLabel(rect_name_l, G("menuitem.prop.label"), EditorStyles.label); - void SingleLabel(int index, Rect block) + void SingleLabel(int index, Rect block, TargetParameter parameter) { var prop_name = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.name)); var prop_icon = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.icon)); @@ -384,7 +478,7 @@ namespace nadena.dev.modular_avatar.core.editor EditorGUI.PropertyField(rect_name, prop_name, GUIContent.none); if (showParams) { - _subParams[index].DoGUI(rect_param, GUIContent.none); + _subParams[index].DoGUI(rect_param, !_hasActions || !HasActions(parameter), GUIContent.none); } var tex = prop_icon.objectReferenceValue as Texture; diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs index 5ca1f0c0..d3879178 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs @@ -7,11 +7,86 @@ using VRC.SDK3.Avatars.ScriptableObjects; namespace nadena.dev.modular_avatar.core.editor { - internal class MenuPreviewGUI + internal class MenuObjectHeader { private const float INDENT_PER_LEVEL = 2; + private static float _indentLevel = 0; + + private UnityEngine.Object _headerObj; + private SerializedProperty _disableProp; + + public MenuObjectHeader(UnityEngine.Object headerObj, SerializedProperty disableProp = null) + { + _headerObj = headerObj; + _disableProp = disableProp; + } + + public static void ClearIndent() + { + _indentLevel = 0; + } + + public IDisposable Scope() + { + GUILayout.BeginHorizontal(); + GUILayout.Space(_indentLevel); + _indentLevel += INDENT_PER_LEVEL; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + if (_headerObj != null) + { + var oldIndent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + try + { + GUILayout.BeginHorizontal(); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.ObjectField(new GUIContent(), _headerObj, _headerObj.GetType(), + true, + GUILayout.ExpandWidth(true)); + } + + if (_disableProp != null) + { + _disableProp.serializedObject.Update(); + GUILayout.Space(10); + GUILayout.Label("Enabled", GUILayout.Width(50)); + EditorGUILayout.PropertyField(_disableProp, GUIContent.none, + GUILayout.Width(EditorGUIUtility.singleLineHeight)); + _disableProp.serializedObject.ApplyModifiedProperties(); + } + + GUILayout.EndHorizontal(); + } + finally + { + EditorGUI.indentLevel = oldIndent; + } + } + + return new ScopeSentinel(); + } + + private class ScopeSentinel : IDisposable + { + public ScopeSentinel() + { + } + + public void Dispose() + { + GUILayout.EndVertical(); + _indentLevel -= INDENT_PER_LEVEL; + GUILayout.EndHorizontal(); + } + } + } + + internal class MenuPreviewGUI + { private Action _redraw; - private float _indentLevel = 0; private readonly Dictionary _guiNodes = new Dictionary(); public MenuPreviewGUI(Action redraw) @@ -21,19 +96,16 @@ namespace nadena.dev.modular_avatar.core.editor public void DoGUI(MenuSource root) { - _indentLevel = 0; new VisitorContext(this).PushNode(root); } public void DoGUI(ModularAvatarMenuInstaller root) { - _indentLevel = 0; new VisitorContext(this).PushMenuInstaller(root); } public void DoGUI(VRCExpressionsMenu menu, GameObject parameterReference = null) { - _indentLevel = 0; new VisitorContext(this).PushNode(menu); } @@ -48,70 +120,6 @@ namespace nadena.dev.modular_avatar.core.editor gui(); } - private class Header - { - private MenuPreviewGUI _gui; - private UnityEngine.Object _headerObj; - private SerializedProperty _disableProp; - - public Header(MenuPreviewGUI gui, UnityEngine.Object headerObj, SerializedProperty disableProp = null) - { - _gui = gui; - _headerObj = headerObj; - _disableProp = disableProp; - } - - public IDisposable Scope() - { - GUILayout.BeginHorizontal(); - GUILayout.Space(_gui._indentLevel); - _gui._indentLevel += INDENT_PER_LEVEL; - - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - if (_headerObj != null) - { - GUILayout.BeginHorizontal(); - using (new EditorGUI.DisabledScope(true)) - { - EditorGUILayout.ObjectField(new GUIContent(), _headerObj, _headerObj.GetType(), - true, - GUILayout.ExpandWidth(true)); - } - - if (_disableProp != null) - { - _disableProp.serializedObject.Update(); - GUILayout.Space(20); - GUILayout.Label("Enabled", GUILayout.Width(50)); - EditorGUILayout.PropertyField(_disableProp, GUIContent.none, - GUILayout.Width(EditorGUIUtility.singleLineHeight)); - _disableProp.serializedObject.ApplyModifiedProperties(); - } - - GUILayout.EndHorizontal(); - } - - return new ScopeSentinel(_gui); - } - - private class ScopeSentinel : IDisposable - { - private readonly MenuPreviewGUI _gui; - - public ScopeSentinel(MenuPreviewGUI gui) - { - _gui = gui; - } - - public void Dispose() - { - GUILayout.EndVertical(); - _gui._indentLevel -= INDENT_PER_LEVEL; - GUILayout.EndHorizontal(); - } - } - } private class VisitorContext : NodeContext { @@ -127,7 +135,7 @@ namespace nadena.dev.modular_avatar.core.editor { _gui.PushGuiNode((expMenu, parameterReference), () => { - var header = new Header(_gui, expMenu); + var header = new MenuObjectHeader(expMenu); var obj = new SerializedObject(expMenu); var controls = obj.FindProperty(nameof(expMenu.controls)); var subGui = new List(); @@ -143,7 +151,7 @@ namespace nadena.dev.modular_avatar.core.editor { foreach (var gui in subGui) { - using (new Header(_gui, null).Scope()) + using (new MenuObjectHeader(null).Scope()) { gui.DoGUI(); } @@ -164,7 +172,7 @@ namespace nadena.dev.modular_avatar.core.editor { _gui.PushGuiNode(item, () => { - var header = new Header(_gui, item, + var header = new MenuObjectHeader(item, new SerializedObject(item.gameObject).FindProperty("m_IsActive")); var gui = new MenuItemCoreGUI(new SerializedObject(item), _gui._redraw); return () => @@ -178,7 +186,7 @@ namespace nadena.dev.modular_avatar.core.editor } else { - using (new Header(_gui, source as UnityEngine.Object).Scope()) + using (new MenuObjectHeader(source as UnityEngine.Object).Scope()) { if (_visited.Contains(source)) return; _visited.Add(source); @@ -190,7 +198,7 @@ namespace nadena.dev.modular_avatar.core.editor public void PushNode(ModularAvatarMenuInstaller installer) { - using (new Header(_gui, installer).Scope()) + using (new MenuObjectHeader(installer).Scope()) { PushMenuInstaller(installer); } diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs index 1f2589bd..5a809a59 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs @@ -23,17 +23,26 @@ namespace nadena.dev.modular_avatar.core.editor _redraw = redraw; } - public void DoGUI(GUIContent label = null) + public void DoGUI(bool enabled, GUIContent label = null) { DoGUI(EditorGUILayout.GetControlRect( true, EditorGUIUtility.singleLineHeight - ), label); + ), enabled, label); } - public void DoGUI(Rect rect, GUIContent label = null) + public void DoGUI(Rect rect, bool enabled, GUIContent label = null) { - if (label == null) label = G("menuitem.prop.parameter"); + label = label ?? G("menuitem.prop.parameter"); + + if (!enabled) + { + using (new EditorGUI.DisabledScope(true)) + { + EditorGUI.TextField(rect, label, S("menuitem.param.controlled_by_action")); + return; + } + } if (_parameterReference != null) GUILayout.Space(-2); GUILayout.BeginHorizontal(); diff --git a/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json b/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json index a0dfca7d..95b406c7 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json +++ b/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json @@ -106,5 +106,15 @@ "menuitem.label.control_labels_and_params": "Control Labels and Parameters", "menuitem.label.control_labels": "Control Labels", "menuitem.misc.multiple": "(multiple)", - "menuitem.misc.no_icon": "(no icon)" + "menuitem.misc.no_icon": "(no icon)", + "menuitem.misc.extract": "Extract to objects", + "menuitem.label.parameters": "Parameters", + "action.toggle_object.header": "Objects to show/hide", + "menu_tree.title": "Select menu", + "menuitem.param.controlled_by_action": "", + "control_group.foldout.actions": "Actions", + "control_group.foldout.menu_items": "Bound menu items", + "menuitem.prop.control_group": "Control Group", + "menuitem.prop.control_group.tooltip": "Only one toggle in a given group can be selected at the same time", + "menuitem.prop.is_default": "Is Group Default" } \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json b/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json index c8a461ae..d91bc6ea 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json +++ b/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json @@ -103,5 +103,15 @@ "menuitem.label.control_labels_and_params": "表示名・パラメーター", "menuitem.label.control_labels": "表示名", "menuitem.misc.multiple": "(複数設定)", - "menuitem.misc.no_icon": "(アイコン無し)" + "menuitem.misc.no_icon": "(アイコン無し)", + "menuitem.misc.extract": "オブジェクトに展開", + "menuitem.label.parameters": "パラメーター", + "action.toggle_object.header": "表示・非表示にするオブジェクト", + "menu_tree.title": "メニューを選択", + "menuitem.param.controlled_by_action": "<アクションで制御されています>", + "control_group.foldout.actions": "アクション", + "control_group.foldout.menu_items": "関連付けされたメニューアイテム", + "menuitem.prop.control_group": "コントロールグループ", + "menuitem.prop.control_group.tooltip": "同じグループ内では、一つのトグルしか起動できません", + "menuitem.prop.is_default": "グループの初期設定にする" } diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta new file mode 100644 index 00000000..a4f309b9 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: d1cf0a36e200446cb099ec448b446495 +timeCreated: 1677334996 +folderAsset: yes \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs new file mode 100644 index 00000000..bbc8afe9 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using nadena.dev.modular_avatar.editor.ErrorReporting; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; +using VRC.SDKBase; +using Object = UnityEngine.Object; + +namespace nadena.dev.modular_avatar.core.editor +{ + internal class ActionGenerator + { + private const string DIRECT_BLEND_TREE_PARAM = "_MA/ONE"; + private readonly BuildContext _context; + + public ActionGenerator(BuildContext context) + { + _context = context; + } + + public void OnPreprocessAvatar(VRCAvatarDescriptor avatar) + { + // Locate MenuActions + var actionMenus = avatar.GetComponentsInChildren(true) + .Select(a => ((Component) a).gameObject.GetComponent()) + .Where(item => item != null) + .ToImmutableHashSet(); + + // Generate the root blendtree and animation; insert into the FX layer + var animLayers = avatar.baseAnimationLayers; + int fxLayerIndex = -1; + AnimatorController controller = null; + + // TODO: refactor out layer manipulation here (+ the base state generator) + + for (int i = 0; i < animLayers.Length; i++) + { + if (animLayers[i].type == VRCAvatarDescriptor.AnimLayerType.FX) + { + fxLayerIndex = i; + controller = _context.DeepCloneAnimator(animLayers[i].animatorController); + break; + } + } + + if (controller == null) + { + controller = new AnimatorController(); + controller.name = "FX Controller"; + _context.SaveAsset(controller); + } + + animLayers[fxLayerIndex].animatorController = controller; + avatar.baseAnimationLayers = animLayers; + + var parameters = controller.parameters.ToList(); + parameters.Add(new AnimatorControllerParameter() + { + name = DIRECT_BLEND_TREE_PARAM, + type = AnimatorControllerParameterType.Float, + defaultFloat = 1, + }); + + var actions = GenerateActions(avatar, actionMenus, parameters); + + controller.parameters = parameters.ToArray(); + + int layersToInsert = 2; // TODO + + var rootBlendTree = GenerateRootBlendLayer(actions); + AdjustAllBehaviors(controller, b => + { + if (b is VRCAnimatorLayerControl lc && lc.playable == VRC_AnimatorLayerControl.BlendableLayer.FX) + { + lc.layer += layersToInsert; + } + }); + foreach (var layer in controller.layers) + { + layer.syncedLayerIndex += layersToInsert; + } + + var layerList = controller.layers.ToList(); + layerList.Insert(0, GenerateBlendshapeBaseLayer(avatar)); + rootBlendTree.defaultWeight = 1; + layerList.Insert(0, rootBlendTree); + layerList[2].defaultWeight = 1; + controller.layers = layerList.ToArray(); + + foreach (var action in avatar.GetComponentsInChildren(true)) + { + Object.DestroyImmediate((UnityEngine.Object) action); + } + } + + private AnimatorControllerLayer GenerateBlendshapeBaseLayer(VRCAvatarDescriptor avatar) + { + AnimatorControllerLayer layer = new AnimatorControllerLayer(); + AnimationClip clip = new AnimationClip(); + foreach (var renderer in avatar.GetComponentsInChildren()) + { + int nShapes = renderer.sharedMesh.blendShapeCount; + for (int i = 0; i < nShapes; i++) + { + float value = renderer.GetBlendShapeWeight(i); + if (value > 0.000001f) + { + clip.SetCurve( + RuntimeUtil.AvatarRootPath(renderer.gameObject), + typeof(SkinnedMeshRenderer), + "blendShape." + renderer.sharedMesh.GetBlendShapeName(i), + AnimationCurve.Constant(0, 1, value) + ); + } + } + } + + layer.stateMachine = new AnimatorStateMachine(); + _context.SaveAsset(layer.stateMachine); + + var state = layer.stateMachine.AddState("Base"); + state.motion = clip; + state.writeDefaultValues = false; + + layer.defaultWeight = 1; + + return layer; + } + + private void AdjustAllBehaviors(AnimatorController controller, Action action) + { + HashSet visited = new HashSet(); + foreach (var layer in controller.layers) + { + VisitStateMachine(layer.stateMachine); + } + + void VisitStateMachine(AnimatorStateMachine layerStateMachine) + { + if (!visited.Add(layerStateMachine)) return; + foreach (var state in layerStateMachine.states) + { + foreach (var behaviour in state.state.behaviours) + { + action(behaviour); + } + } + + foreach (var child in layerStateMachine.stateMachines) + { + VisitStateMachine(child.stateMachine); + } + } + } + + private AnimatorControllerLayer GenerateRootBlendLayer(List actions) + { + var motion = new BlendTree(); + motion.name = "Menu Actions (generated)"; + motion.blendParameter = DIRECT_BLEND_TREE_PARAM; + motion.blendType = BlendTreeType.Direct; + motion.children = actions.Select(a => new ChildMotion() + { + motion = a, + directBlendParameter = DIRECT_BLEND_TREE_PARAM, + timeScale = 1, + }).ToArray(); + + var layer = new AnimatorControllerLayer(); + layer.name = "Menu Actions (generated)"; + layer.defaultWeight = 1; + layer.blendingMode = AnimatorLayerBlendingMode.Override; + layer.stateMachine = new AnimatorStateMachine(); + layer.stateMachine.name = "Menu Actions (generated)"; + //layer.stateMachine.hideFlags = HideFlags.HideInHierarchy; + _context.SaveAsset(layer.stateMachine); + + var rootState = layer.stateMachine.AddState("Root"); + rootState.motion = motion; + + return layer; + } + + private List GenerateActions( + VRCAvatarDescriptor descriptor, + IEnumerable items, + List acParameters) + { + var expParams = descriptor.expressionParameters; + if (expParams != null) + { + expParams = Object.Instantiate(expParams); + _context.SaveAsset(expParams); + } + else + { + expParams = ScriptableObject.CreateInstance(); + expParams.name = "Expression Parameters"; + _context.SaveAsset(expParams); + descriptor.expressionParameters = expParams; + } + + List expParameters = expParams.parameters.ToList(); + List blendTrees = new List(); + + Dictionary> groupedItems = + new Dictionary>(); + + foreach (var item in items) + { + List group; + if (item.controlGroup) + { + if (!groupedItems.TryGetValue(item.controlGroup, out group)) + { + group = new List(); + groupedItems.Add(item.controlGroup, group); + } + } + else + { + group = new List(); + groupedItems.Add(item, group); + } + + group.Add(item); + } + + int paramIndex = 0; + foreach (var kvp in groupedItems) + { + // sort default first + var group = kvp.Value; + group.Sort((a, b) => b.isDefault.CompareTo(a.isDefault)); + + // Generate parameter + var paramname = "_MA/A/" + kvp.Key.gameObject.name + "/" + (paramIndex++); + + var expParamType = group.Count > 1 + ? VRCExpressionParameters.ValueType.Int + : VRCExpressionParameters.ValueType.Bool; + + expParameters.Add(new VRCExpressionParameters.Parameter() + { + name = paramname, + defaultValue = 0, // TODO + valueType = expParamType, + saved = false, // TODO + }); + acParameters.Add(new AnimatorControllerParameter() + { + name = paramname, + type = AnimatorControllerParameterType.Float, + defaultFloat = 0, // TODO + }); + + var hasDefault = group[0].isDefault; + for (int i = 0; i < group.Count; i++) + { + var control = group[i].Control; + control.parameter = new VRCExpressionsMenu.Control.Parameter() {name = paramname}; + control.value = hasDefault ? i : i + 1; + } + + var blendTree = new BlendTree(); + blendTree.name = paramname; + blendTree.blendParameter = paramname; + blendTree.blendType = BlendTreeType.Simple1D; + blendTree.useAutomaticThresholds = false; + + List children = new List(); + + List motions = GenerateMotions(group, out var inactiveMotion); + + if (!hasDefault) + { + motions.Insert(0, inactiveMotion); + } + + for (int i = 0; i < motions.Count; i++) + { + children.Add(new ChildMotion() + { + motion = motions[i], + position = new Vector2(i, 0), + threshold = i - 0.1f, + timeScale = 1, + }); + children.Add(new ChildMotion() + { + motion = motions[i], + position = new Vector2(i, 0), + threshold = i + 0.1f, + timeScale = 1, + }); + } + + blendTree.children = children.ToArray(); + + _context.SaveAsset(blendTree); + blendTrees.Add(blendTree); + } + + expParams.parameters = expParameters.ToArray(); + descriptor.expressionParameters = expParams; + + return blendTrees; + } + + void MergeCurves( + IDictionary curves, + ModularAvatarMenuItem item, + Func> getCurves, + bool ignoreDuplicates + ) + { + foreach (var action in item.GetComponents()) + { + var newCurves = getCurves(action); + + foreach (var curvePair in newCurves) + { + var binding = curvePair.Key; + var curve = curvePair.Value; + + if (curves.TryGetValue(binding, out var existing)) + { + if (!ignoreDuplicates) + { + BuildReport.LogFatal("animation_gen.conflict", new object[] + { + binding, + existing.Item1.gameObject.name, + existing.Item1.GetType().Name, + item.gameObject.name, + item.GetType().Name + }, binding.target, existing.Item1, item); + } + } + else + { + curves.Add(binding, (item, curve)); + } + } + } + } + + private List GenerateMotions(List items, out Motion inactiveMotion) + { + Dictionary inactiveCurves = + new Dictionary(); + + var defaultItems = items.Where(i => i.isDefault).ToList(); + if (defaultItems.Count > 1) + { + BuildReport.LogFatal("animation_gen.multiple_defaults", + strings: Array.Empty(), + objects: defaultItems.ToArray() + ); + defaultItems.RemoveRange(1, defaultItems.Count - 1); + } + + if (defaultItems.Count > 0) + { + MergeCurves(inactiveCurves, defaultItems[0], a => a.GetInactiveCurves(true), false); + } + + foreach (var item in items) + { + if (defaultItems.Count == 0 || defaultItems[0] != item) + { + MergeCurves(inactiveCurves, item, a => a.GetInactiveCurves(false), true); + } + } + + inactiveMotion = CurvesToMotion(inactiveCurves); + var groupName = (items[0].controlGroup != null + ? items[0].controlGroup.gameObject.name + : items[0].gameObject.name); + inactiveMotion.name = + groupName + + " (Inactive)"; + + List motions = new List(); + + foreach (var item in items) + { + Dictionary activeCurves = + new Dictionary(); + + MergeCurves(activeCurves, item, a => a.GetCurves(), false); + foreach (var kvp in inactiveCurves) + { + if (!activeCurves.ContainsKey(kvp.Key)) + { + activeCurves.Add(kvp.Key, kvp.Value); + } + } + + var clip = CurvesToMotion(activeCurves); + clip.name = groupName + " (" + item.gameObject.name + ")"; + motions.Add(clip); + } + + return motions; + } + + Motion CurvesToMotion(IDictionary curves) + { + var clip = new AnimationClip(); + _context.SaveAsset(clip); + foreach (var entry in curves) + { + clip.SetCurve( + RuntimeUtil.AvatarRootPath(entry.Key.target), + entry.Key.type, + entry.Key.property, + entry.Value.Item2 // curve + ); + } + + return clip; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta new file mode 100644 index 00000000..afb8152e --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: be5c2a67d3b448c5bd8c439f537a1766 +timeCreated: 1677335026 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta new file mode 100644 index 00000000..e354c23a --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 31ce1123a74443c1ba7a126d4b8919b1 +timeCreated: 1677317241 +folderAsset: yes \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs new file mode 100644 index 00000000..d62ac780 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using UnityEngine; +using Object = System.Object; + +namespace nadena.dev.modular_avatar.core +{ + [RequireComponent(typeof(ModularAvatarMenuItem))] + [AddComponentMenu("Modular Avatar/MA Action Toggle Object")] + public class ActionToggleObject : AvatarTagComponent, SwitchedMenuAction + { + [Serializable] + public class ObjectEntry + { + public GameObject target; + public bool Active; + } + + public List Objects; + + protected override void OnValidate() + { + base.OnValidate(); + + if (Objects == null) + { + Objects = new List(); + } + } + + public ImmutableDictionary GetCurves() + { + return Objects.Select(obj => + new KeyValuePair( + new MenuCurveBinding(obj.target, typeof(GameObject), "m_IsActive"), + AnimationCurve.Constant(0, 1, obj.Active ? 1 : 0)) + ).ToImmutableDictionary(); + } + + public ImmutableDictionary GetInactiveCurves(bool isDefault) + { + return Objects.Select(obj => + { + bool active; + if (isDefault) + { + active = !obj.Active; // inactive state is the opposite of the default state + } + else + { + active = obj.target.activeSelf; // inactive state is the current state + } + + return new KeyValuePair( + new MenuCurveBinding(obj.target, typeof(GameObject), "m_IsActive"), + AnimationCurve.Constant(0, 1, active ? 1 : 0)); + } + ).ToImmutableDictionary(); + } + + public bool BindsParameter(TargetParameter parameter) + { + return parameter == TargetParameter.BaseParameter; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta new file mode 100644 index 00000000..a0b00d61 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fcac4d9412424173bd294ffd5fc5f9db +timeCreated: 1677316809 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs new file mode 100644 index 00000000..c0abf028 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs @@ -0,0 +1,9 @@ +using UnityEngine; + +namespace nadena.dev.modular_avatar.core +{ + [AddComponentMenu("Modular Avatar/MA Control Group")] + public class ControlGroup : AvatarTagComponent + { + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta new file mode 100644 index 00000000..c04947a3 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 99c60d83ad614e81a0488d98b83b5c1c +timeCreated: 1677317301 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs new file mode 100644 index 00000000..156b1400 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Immutable; +using UnityEngine; + +namespace nadena.dev.modular_avatar.core +{ + public interface MenuAction + { + /// + /// Returns whether this action binds to the given parameter. + /// + /// + /// + bool BindsParameter(TargetParameter parameter); + } + + public interface SwitchedMenuAction : MenuAction + { + /// + /// Returns the curves applied when this action is active + /// + /// + ImmutableDictionary GetCurves(); + + /// + /// Returns the curves applied when this action is inactive (and no other actions override). + /// + /// True if this action is part of the default toggle option. + /// + ImmutableDictionary GetInactiveCurves(bool isDefault); + } + + public enum TargetParameter + { + BaseParameter, + RadialParam, + Horizontal, + Vertical, + Up, + Right, + Down, + Left + } + + public static class TargetParameterExtension + { + public static int Index(this TargetParameter p) + { + switch (p) + { + case TargetParameter.BaseParameter: + return -1; + case TargetParameter.RadialParam: return 0; + case TargetParameter.Horizontal: return 0; + case TargetParameter.Vertical: return 1; + case TargetParameter.Up: return 0; + case TargetParameter.Right: return 1; + case TargetParameter.Down: return 2; + case TargetParameter.Left: return 3; + default: throw new NotImplementedException(); + } + } + } + + public sealed class MenuCurveBinding + { + public readonly GameObject target; + public readonly Type type; + public readonly string property; + + public MenuCurveBinding(GameObject target, Type type, string property) + { + this.target = target; + this.type = type; + this.property = property; + } + + private bool Equals(MenuCurveBinding other) + { + return target == other.target && type == other.type && property == other.property; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((MenuCurveBinding) obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (target != null ? target.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (type != null ? type.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (property != null ? property.GetHashCode() : 0); + return hashCode; + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta new file mode 100644 index 00000000..8b49f440 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 33005b045768474d8ae6f789aa03361b +timeCreated: 1677595006 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs index 79c35be5..051fa26b 100644 --- a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs @@ -2,6 +2,7 @@ using System.Linq; using nadena.dev.modular_avatar.core.menu; using UnityEngine; +using UnityEngine.Serialization; using VRC.SDK3.Avatars.ScriptableObjects; namespace nadena.dev.modular_avatar.core @@ -20,6 +21,9 @@ namespace nadena.dev.modular_avatar.core public GameObject menuSource_otherObjectChildren; + [FormerlySerializedAs("toggleGroup")] public ControlGroup controlGroup; + public bool isDefault; + public override void Visit(NodeContext context) { var cloned = new VirtualControl(Control);