implement the core virtual menu infrastructure

This commit is contained in:
bd_ 2023-02-22 20:59:40 +09:00
parent d212dabc27
commit 0bc1c6c88e
15 changed files with 1062 additions and 19 deletions

View File

@ -15,13 +15,13 @@ namespace modular_avatar_tests
private const string MinimalAvatarGuid = "60d3416d1f6af4a47bf9056aefc38333";
[SetUp]
public void Setup()
public virtual void Setup()
{
objects = new List<GameObject>();
}
[TearDown]
public void Teardown()
public virtual void Teardown()
{
foreach (var obj in objects)
{

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 10cb5a7057c1452d8e1caf299c31b05b
timeCreated: 1676981463

View File

@ -0,0 +1,553 @@
using System.Collections.Generic;
using nadena.dev.modular_avatar.core;
using nadena.dev.modular_avatar.core.editor.menu;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace modular_avatar_tests.VirtualMenuTests
{
public class VirtualMenuTests : TestBase
{
private Texture2D testTex;
private List<UnityEngine.Object> toDestroy;
public override void Setup()
{
base.Setup();
testTex = new Texture2D(1, 1);
toDestroy = new List<UnityEngine.Object>();
}
public override void Teardown()
{
base.Teardown();
Object.DestroyImmediate(testTex);
foreach (var obj in toDestroy)
{
Object.DestroyImmediate(obj);
}
}
private T Create<T>(string name = null) where T : ScriptableObject
{
if (name == null) name = GUID.Generate().ToString();
T val = ScriptableObject.CreateInstance<T>();
val.name = name;
toDestroy.Add(val);
return val;
}
[Test]
public void TestEmptyMenu()
{
var virtualMenu = new VirtualMenu(null);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(0, root.Controls.Count);
Assert.AreSame(RootMenu.Instance, root.NodeKey);
}
[Test]
public void TestBasicMenu()
{
var rootMenu = Create<VRCExpressionsMenu>();
rootMenu.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl(),
GenerateTestControl()
};
var virtualMenu = new VirtualMenu(rootMenu);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[rootMenu];
Assert.AreEqual(2, root.Controls.Count);
Assert.AreSame(rootMenu, root.NodeKey);
AssertControlEquals(rootMenu.controls[0], root.Controls[0]);
AssertControlEquals(rootMenu.controls[1], root.Controls[1]);
}
[Test]
public void TestNativeMenuWithCycles()
{
var rootMenu = Create<VRCExpressionsMenu>("root");
var sub1 = Create<VRCExpressionsMenu>("sub1");
var sub2 = Create<VRCExpressionsMenu>("sub2");
rootMenu.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(sub1)
};
sub1.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(sub2)
};
sub2.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(rootMenu)
};
var virtualMenu = new VirtualMenu(rootMenu);
virtualMenu.FreezeMenu();
Assert.AreEqual(3, virtualMenu.ResolvedMenu.Count);
var rootNode = virtualMenu.ResolvedMenu[rootMenu];
var sub1Node = virtualMenu.ResolvedMenu[sub1];
var sub2Node = virtualMenu.ResolvedMenu[sub2];
Assert.AreEqual(1, rootNode.Controls.Count);
Assert.AreSame(rootMenu, rootNode.NodeKey);
Assert.AreSame(sub1Node, rootNode.Controls[0].SubmenuNode);
Assert.IsNull(rootNode.Controls[0].subMenu);
Assert.AreEqual(1, sub1Node.Controls.Count);
Assert.AreSame(sub1, sub1Node.NodeKey);
Assert.AreSame(sub2Node, sub1Node.Controls[0].SubmenuNode);
Assert.IsNull(sub1Node.Controls[0].subMenu);
Assert.AreEqual(1, sub2Node.Controls.Count);
Assert.AreSame(sub2, sub2Node.NodeKey);
Assert.AreSame(rootNode, sub2Node.Controls[0].SubmenuNode);
Assert.IsNull(sub2Node.Controls[0].subMenu);
}
[Test]
public void TestBasicMenuInstaller()
{
VRCExpressionsMenu testMenu = Create<VRCExpressionsMenu>();
testMenu.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl(),
GenerateTestControl()
};
var installer = CreateInstaller("test");
installer.menuToAppend = testMenu;
var virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(2, root.Controls.Count);
Assert.AreSame(RootMenu.Instance, root.NodeKey);
AssertControlEquals(testMenu.controls[0], root.Controls[0]);
AssertControlEquals(testMenu.controls[1], root.Controls[1]);
}
[Test]
public void TestMenuItemInstaller()
{
var installer = CreateInstaller("test");
installer.menuToAppend = Create<VRCExpressionsMenu>();
var item = installer.gameObject.AddComponent<ModularAvatarMenuItem>();
item.Control = GenerateTestControl();
var virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer);
virtualMenu.FreezeMenu();
item.Control.name = "test";
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(1, root.Controls.Count);
Assert.AreSame(RootMenu.Instance, root.NodeKey);
AssertControlEquals(item.Control, root.Controls[0]);
}
[Test]
public void TestInstallOntoInstaller()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var installer_c = CreateInstaller("c");
var menu_a = Create<VRCExpressionsMenu>();
var menu_b = Create<VRCExpressionsMenu>();
var menu_c = Create<VRCExpressionsMenu>();
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
menu_c.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
installer_b.menuToAppend = menu_b;
installer_c.menuToAppend = menu_c;
installer_c.installTargetMenu = installer_a.menuToAppend;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.RegisterMenuInstaller(installer_c);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(3, rootMenu.Controls.Count);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
AssertControlEquals(menu_a.controls[0], rootMenu.Controls[0]);
AssertControlEquals(menu_c.controls[0], rootMenu.Controls[1]);
AssertControlEquals(menu_b.controls[0], rootMenu.Controls[2]);
}
[Test]
public void TestInstallSubmenu()
{
var installer_a = CreateInstaller("a");
var menu_a = Create<VRCExpressionsMenu>("a");
var menu_b = Create<VRCExpressionsMenu>("b");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(menu_b)
};
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.FreezeMenu();
Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
var subMenu = virtualMenu.ResolvedMenu[menu_b];
Assert.AreSame(subMenu, rootMenu.Controls[0].SubmenuNode);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
Assert.AreSame(menu_b, subMenu.NodeKey);
Assert.AreEqual(1, subMenu.Controls.Count);
AssertControlEquals(menu_b.controls[0], subMenu.Controls[0]);
}
[Test]
public void TestYankInstaller()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_a = Create<VRCExpressionsMenu>("a");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
var item = installer_b.gameObject.AddComponent<ModularAvatarMenuItem>();
item.Control = GenerateTestControl();
item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
item.MenuSource = SubmenuSource.Children;
var child = CreateChild(item.gameObject, "child");
var childItem = child.AddComponent<ModularAvatarMenuInstallTarget>();
childItem.installer = installer_a;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.RegisterMenuInstallTarget(childItem);
virtualMenu.FreezeMenu();
item.Control.name = "b";
Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
var item_node = virtualMenu.ResolvedMenu[item];
Assert.AreEqual(1, rootMenu.Controls.Count);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
AssertControlEquals(item.Control, rootMenu.Controls[0]);
Assert.AreSame(item_node, rootMenu.Controls[0].SubmenuNode);
Assert.AreEqual(1, item_node.Controls.Count);
Assert.AreSame(item, item_node.NodeKey);
AssertControlEquals(menu_a.controls[0], item_node.Controls[0]);
}
[Test]
public void WhenMenuInstallersLoop_LoopIsTerminated()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_a = Create<VRCExpressionsMenu>("a");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
var menu_b = Create<VRCExpressionsMenu>("b");
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_b.menuToAppend = menu_b;
installer_a.installTargetMenu = menu_b;
installer_b.installTargetMenu = menu_a;
VirtualMenu virtualMenu = new VirtualMenu(menu_a);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[menu_a];
var menu_a_node = virtualMenu.ResolvedMenu[menu_a];
Assert.AreEqual(3, rootMenu.Controls.Count);
}
[Test]
public void TestExternalSubmenuSource_WithMenuInstaller()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_a = Create<VRCExpressionsMenu>("a");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
var menu_b = Create<VRCExpressionsMenu>("b");
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
var item_a = installer_a.gameObject.AddComponent<ModularAvatarMenuItem>();
item_a.Control = GenerateTestControl();
item_a.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
item_a.MenuSource = SubmenuSource.External;
item_a.Control.subMenu = menu_a;
installer_b.menuToAppend = menu_b;
installer_b.installTargetMenu = menu_a;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.FreezeMenu();
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type);
var menu_a_node = rootNode.Controls[0].SubmenuNode;
AssertControlEquals(menu_a.controls[0], menu_a_node.Controls[0]);
AssertControlEquals(menu_b.controls[0], menu_a_node.Controls[1]);
}
[Test]
public void MenuItem_NestedSubmenuNodes()
{
var installer_a = CreateInstaller("root");
var root_item = installer_a.gameObject.AddComponent<ModularAvatarMenuItem>();
root_item.Control = GenerateTestControl();
root_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
root_item.MenuSource = SubmenuSource.Children;
var mid_obj = CreateChild(root_item.gameObject, "mid");
var mid_item = mid_obj.AddComponent<ModularAvatarMenuItem>();
mid_item.Control = GenerateTestControl();
mid_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
mid_item.MenuSource = SubmenuSource.Children;
var leaf_obj = CreateChild(mid_obj, "leaf");
var leaf_item = leaf_obj.AddComponent<ModularAvatarMenuItem>();
leaf_item.Control = GenerateTestControl();
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.FreezeMenu();
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type);
var mid_node = rootNode.Controls[0].SubmenuNode;
var leaf_node = mid_node.Controls[0].SubmenuNode;
Assert.AreEqual(1, rootNode.Controls.Count);
Assert.AreEqual(1, mid_node.Controls.Count);
Assert.AreEqual(1, leaf_node.Controls.Count);
root_item.Control.name = "root";
mid_item.Control.name = "mid";
leaf_item.Control.name = "leaf";
AssertControlEquals(root_item.Control, rootNode.Controls[0]);
AssertControlEquals(mid_item.Control, mid_node.Controls[0]);
AssertControlEquals(leaf_item.Control, leaf_node.Controls[0]);
}
[Test]
public void MenuItem_RemoteReference()
{
var installer_a = CreateInstaller("root");
var root_item = installer_a.gameObject.AddComponent<ModularAvatarMenuItem>();
var extern_root = CreateRoot("test");
var extern_obj = CreateChild(extern_root, "control");
var extern_item = extern_obj.AddComponent<ModularAvatarMenuItem>();
extern_item.Control = GenerateTestControl();
root_item.Control = GenerateTestControl();
root_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
root_item.MenuSource = SubmenuSource.Children;
root_item.menuSource_otherObjectChildren = extern_root;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.FreezeMenu();
root_item.Control.name = "root";
extern_item.Control.name = "control";
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type);
var extern_node = rootNode.Controls[0].SubmenuNode;
Assert.AreEqual(1, rootNode.Controls.Count);
Assert.AreEqual(1, extern_node.Controls.Count);
AssertControlEquals(root_item.Control, rootNode.Controls[0]);
AssertControlEquals(extern_item.Control, extern_node.Controls[0]);
}
[Test]
public void InstallerInstallsInstallTarget()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_b = Create<VRCExpressionsMenu>("b");
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_b.menuToAppend = menu_b;
var item_a = installer_a.gameObject.AddComponent<ModularAvatarMenuInstallTarget>();
item_a.installer = installer_b;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.RegisterMenuInstallTarget(item_a);
virtualMenu.FreezeMenu();
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(1, rootNode.Controls.Count);
AssertControlEquals(menu_b.controls[0], rootNode.Controls[0]);
}
ModularAvatarMenuInstaller CreateInstaller(string name)
{
GameObject obj = new GameObject();
obj.name = name;
var installer = obj.AddComponent<ModularAvatarMenuInstaller>();
installer.name = name;
toDestroy.Add(obj);
return installer;
}
VRCExpressionsMenu.Control GenerateTestSubmenu(VRCExpressionsMenu menu)
{
var control = GenerateTestControl();
control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
control.subMenu = menu;
return control;
}
VRCExpressionsMenu.Control GenerateTestControl()
{
var control = new VRCExpressionsMenu.Control();
VRCExpressionsMenu.Control.ControlType[] types = new[]
{
VRCExpressionsMenu.Control.ControlType.Button,
// VRCExpressionsMenu.Control.ControlType.SubMenu,
VRCExpressionsMenu.Control.ControlType.Toggle,
VRCExpressionsMenu.Control.ControlType.RadialPuppet,
VRCExpressionsMenu.Control.ControlType.FourAxisPuppet,
VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet,
VRCExpressionsMenu.Control.ControlType.OneAxisPuppet,
};
control.type = types[Random.Range(0, types.Length)];
control.name = "Test Control " + GUID.Generate();
control.parameter = new VRCExpressionsMenu.Control.Parameter();
control.parameter.name = "Test Parameter " + GUID.Generate();
control.icon = new Texture2D(1, 1);
control.labels = new[]
{
new VRCExpressionsMenu.Control.Label()
{
name = "label",
icon = testTex
}
};
control.subParameters = new[]
{
new VRCExpressionsMenu.Control.Parameter()
{
name = "Test Sub Parameter " + GUID.Generate()
}
};
control.value = 0.42f;
control.style = VRCExpressionsMenu.Control.Style.Style3;
return control;
}
void AssertControlEquals(VRCExpressionsMenu.Control expected, VRCExpressionsMenu.Control actual)
{
Assert.AreEqual(expected.type, actual.type);
Assert.AreEqual(expected.name, actual.name);
Assert.AreEqual(expected.parameter.name, actual.parameter.name);
Assert.AreNotSame(expected.parameter, actual.parameter);
Assert.AreEqual(expected.icon, actual.icon);
Assert.AreEqual(expected.labels.Length, actual.labels.Length);
Assert.AreEqual(expected.labels[0].name, actual.labels[0].name);
Assert.AreEqual(expected.labels[0].icon, actual.labels[0].icon);
Assert.AreEqual(expected.subParameters.Length, actual.subParameters.Length);
Assert.AreEqual(expected.subParameters[0].name, actual.subParameters[0].name);
Assert.AreNotSame(expected.subParameters[0], actual.subParameters[0]);
Assert.AreEqual(expected.value, actual.value);
Assert.AreEqual(expected.style, actual.style);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c96564b3caa04c63aa9c7c4ce05429eb
timeCreated: 1676981475

View File

@ -0,0 +1,28 @@
using System;
using System.Runtime.Serialization;
using JetBrains.Annotations;
namespace nadena.dev.modular_avatar.editor.ErrorReporting
{
/// <summary>
/// These exceptions will not be logged in the error report.
/// </summary>
public class NominalException : Exception
{
public NominalException()
{
}
protected NominalException([NotNull] SerializationInfo info, StreamingContext context) : base(info, context)
{
}
public NominalException(string message) : base(message)
{
}
public NominalException(string message, Exception innerException) : base(message, innerException)
{
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 152d780db95c4e408240ec3cd4dad60e
timeCreated: 1676979798

View File

@ -5,7 +5,7 @@ using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor
{
[CustomEditor(typeof(MAMenuItem))]
[CustomEditor(typeof(ModularAvatarMenuItem))]
internal class MAMenuItemInspector : MAEditorBase
{
private SerializedProperty prop_submenu_source;
@ -14,9 +14,10 @@ namespace nadena.dev.modular_avatar.core.editor
void OnEnable()
{
prop_control = serializedObject.FindProperty(nameof(MAMenuItem.Control));
prop_submenu_source = serializedObject.FindProperty(nameof(MAMenuItem.MenuSource));
prop_otherObjChildren = serializedObject.FindProperty(nameof(MAMenuItem.menuSource_otherObjectChildren));
prop_control = serializedObject.FindProperty(nameof(ModularAvatarMenuItem.Control));
prop_submenu_source = serializedObject.FindProperty(nameof(ModularAvatarMenuItem.MenuSource));
prop_otherObjChildren =
serializedObject.FindProperty(nameof(ModularAvatarMenuItem.menuSource_otherObjectChildren));
}
private void DrawControlSettings(SerializedProperty control, string name = null,
@ -25,7 +26,7 @@ namespace nadena.dev.modular_avatar.core.editor
if (name != null)
{
EditorGUI.BeginChangeCheck();
var targetGameObject = ((MAMenuItem) target).gameObject;
var targetGameObject = ((ModularAvatarMenuItem) target).gameObject;
var newName = EditorGUILayout.TextField("Name", targetGameObject.name);
if (EditorGUI.EndChangeCheck() && commitName != null)
{
@ -51,7 +52,7 @@ namespace nadena.dev.modular_avatar.core.editor
if (!multiEdit)
{
EditorGUI.BeginChangeCheck();
var targetGameObject = ((MAMenuItem) target).gameObject;
var targetGameObject = ((ModularAvatarMenuItem) target).gameObject;
name = targetGameObject.name;
commitName = newName =>
{
@ -68,7 +69,7 @@ namespace nadena.dev.modular_avatar.core.editor
if (multiEdit) return;
var menuItem = (MAMenuItem) target;
var menuItem = (ModularAvatarMenuItem) target;
if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
GUILayout.Space(EditorStyles.label.lineHeight);
@ -92,7 +93,7 @@ namespace nadena.dev.modular_avatar.core.editor
: menuItem.gameObject;
foreach (Transform t in source.transform)
{
var child = t.GetComponent<MAMenuItem>();
var child = t.GetComponent<ModularAvatarMenuItem>();
if (child == null) continue;
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
@ -100,7 +101,8 @@ namespace nadena.dev.modular_avatar.core.editor
GUILayout.BeginHorizontal();
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.ObjectField(new GUIContent(), child, typeof(MAMenuItem), true,
EditorGUILayout.ObjectField(new GUIContent(), child, typeof(ModularAvatarMenuItem),
true,
GUILayout.ExpandWidth(true));
}
@ -125,7 +127,7 @@ namespace nadena.dev.modular_avatar.core.editor
};
var childSO = new SerializedObject(child);
var childControl = childSO.FindProperty(nameof(MAMenuItem.Control));
var childControl = childSO.FindProperty(nameof(ModularAvatarMenuItem.Control));
DrawControlSettings(childControl, name, commitName);
childSO.ApplyModifiedProperties();

View File

@ -44,15 +44,15 @@ namespace nadena.dev.modular_avatar.core.editor
itemObj.transform.localRotation = Quaternion.identity;
itemObj.transform.localScale = Vector3.one;
var menuItem = itemObj.AddComponent<MAMenuItem>();
var menuItem = itemObj.AddComponent<ModularAvatarMenuItem>();
menuItem.Control = sourceControl;
if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
if (convertedMenus.TryGetValue(sourceControl.subMenu, out var otherSource))
{
menuItem.MenuSource = SubmenuSource.OtherMenuItem;
menuItem.menuSource_otherSource = otherSource;
menuItem.MenuSource = SubmenuSource.Children;
menuItem.menuSource_otherObjectChildren = otherSource.gameObject;
}
else
{

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 435b9162d1fd4050b9ced045ab20af27
timeCreated: 1676977199

View File

@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.Linq;
using nadena.dev.modular_avatar.editor.ErrorReporting;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor.menu
{
/// <summary>
/// Sentinel object to represent the avatar root menu (for avatars which don't have a root menu)
/// </summary>
internal sealed class RootMenu
{
public static readonly RootMenu Instance = new RootMenu();
private RootMenu()
{
}
}
/// <summary>
/// A MenuNode represents a single VRCExpressionsMenu, prior to overflow splitting. MenuNodes form a directed graph,
/// which may contain cycles, and may include contributions from multiple MenuInstallers, or from the base avatar
/// menu.
/// </summary>
internal class MenuNode
{
internal List<VirtualControl> Controls = new List<VirtualControl>();
/// <summary>
/// The primary (serialized) object that contributed to this menu; if we want to add more items to it, we look
/// here. This can currently be either a VRCExpressionsMenu, a MAMenuItem, or a RootMenu.
/// </summary>
internal readonly object NodeKey;
internal MenuNode(object nodeKey)
{
NodeKey = nodeKey;
}
}
internal class VirtualControl : VRCExpressionsMenu.Control
{
/// <summary>
/// VirtualControls do not reference real VRCExpressionsMenu objects, but rather virtual MenuNodes.
/// </summary>
internal MenuNode SubmenuNode;
internal VirtualControl(VRCExpressionsMenu.Control control)
{
this.name = control.name;
this.type = control.type;
this.parameter = new Parameter() {name = control.parameter.name};
this.value = control.value;
this.icon = control.icon;
this.style = control.style;
this.subMenu = null;
this.subParameters = control.subParameters.Select(p => new VRCExpressionsMenu.Control.Parameter()
{
name = p.name
}).ToArray();
this.labels = control.labels.ToArray();
}
}
/**
* The VirtualMenu class tracks a fully realized shadow menu. Notably, this is _not_ converted to unity
* ScriptableObjects, making it easier to discard it when we need to update it.
*/
internal class VirtualMenu
{
private readonly object RootMenuKey;
/// <summary>
/// Indexes which menu installers are contributing to which VRCExpressionMenu assets.
/// </summary>
private Dictionary<object, List<ModularAvatarMenuInstaller>> _targetMenuToInstaller
= new Dictionary<object, List<ModularAvatarMenuInstaller>>();
private Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>> _installerToTargetComponent
= new Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>>();
/// <summary>
/// Maps from either VRCEXpressionsMenu objects or MenuItems to menu nodes. The ROOT_MENU here is a special
/// object used to mark contributors to the avatar root menu.
/// </summary>
private Dictionary<object, MenuNode> _menuNodeMap = new Dictionary<object, MenuNode>();
private Dictionary<object, MenuNode> _resolvedMenu = new Dictionary<object, MenuNode>();
// TODO: immutable?
public Dictionary<object, MenuNode> ResolvedMenu => _resolvedMenu;
/// <summary>
/// Initializes the VirtualMenu.
/// </summary>
/// <param name="rootMenu">The root VRCExpressionsMenu to import</param>
internal VirtualMenu(VRCExpressionsMenu rootMenu)
{
if (rootMenu != null)
{
RootMenuKey = rootMenu;
ImportMenu(rootMenu);
}
else
{
RootMenuKey = RootMenu.Instance;
_menuNodeMap[RootMenu.Instance] = new MenuNode(RootMenu.Instance);
}
}
private MenuNode ImportMenu(VRCExpressionsMenu menu, object menuKey = null)
{
if (menuKey == null) menuKey = menu;
if (_menuNodeMap.TryGetValue(menuKey, out var subMenuNode)) return subMenuNode;
var node = new MenuNode(menuKey);
_menuNodeMap[menuKey] = node;
foreach (var control in menu.controls)
{
var virtualControl = new VirtualControl(control);
if (control.subMenu != null)
{
virtualControl.SubmenuNode = ImportMenu(control.subMenu);
}
node.Controls.Add(virtualControl);
}
return node;
}
/// <summary>
/// Registers a menu installer with this virtual menu. Because we need the full set of components indexed to
/// determine the effects of this menu installer, further processing is deferred until we freeze the menu.
/// </summary>
/// <param name="installer"></param>
internal void RegisterMenuInstaller(ModularAvatarMenuInstaller installer)
{
// initial validation
if (installer.menuToAppend == null && installer.GetComponent<MenuSource>() == null) return;
var target = installer.installTargetMenu ? (object) installer.installTargetMenu : RootMenuKey;
if (!_targetMenuToInstaller.TryGetValue(target, out var targets))
{
targets = new List<ModularAvatarMenuInstaller>();
_targetMenuToInstaller[target] = targets;
}
targets.Add(installer);
}
/// <summary>
/// Registers an install target with this virtual menu. As with menu installers, processing is delayed.
/// </summary>
/// <param name="target"></param>
internal void RegisterMenuInstallTarget(ModularAvatarMenuInstallTarget target)
{
if (target.installer == null) return;
if (!_installerToTargetComponent.TryGetValue(target.installer, out var targets))
{
targets = new List<ModularAvatarMenuInstallTarget>();
_installerToTargetComponent[target.installer] = targets;
}
}
/// <summary>
/// Freezes the menu, fully resolving all members of all menus.
/// </summary>
internal void FreezeMenu()
{
ResolveNode(RootMenuKey);
}
private HashSet<object> _sourceTrace = null;
private MenuNode ResolveNode(object nodeKey)
{
if (_resolvedMenu.TryGetValue(nodeKey, out var node)) return node;
if (nodeKey is ModularAvatarMenuItem item)
{
return ResolveSubmenuItem(item);
}
if (nodeKey is VRCExpressionsMenu menu)
{
ImportMenu(menu);
}
if (_menuNodeMap.TryGetValue(nodeKey, out node))
{
_resolvedMenu[nodeKey] = node;
}
else
{
node = new MenuNode(nodeKey);
_menuNodeMap[nodeKey] = node;
_resolvedMenu[nodeKey] = node;
}
// Find any menu installers which target this node, and recursively include them.
// Note that we're also recursing through MenuNodes, and should not consider the objects visited on
// different submenus when cutting off cycles.
var priorTrace = _sourceTrace;
_sourceTrace = new HashSet<object>();
try
{
// We use a stack here to maintain the expected order of elements. Consider if we have three menu
// installers as follows:
// A -> root
// B -> root
// C -> A
// We'll first push [B, A], then visit A. At this point we'll push C back on the stack, so we visit
// [A, C, B] in the end.
Stack<ModularAvatarMenuInstaller> installers = new Stack<ModularAvatarMenuInstaller>();
if (_targetMenuToInstaller.TryGetValue(nodeKey, out var rootInstallers))
{
foreach (var i in rootInstallers.Select(x => x).Reverse())
{
if (_installerToTargetComponent.ContainsKey(i)) continue;
installers.Push(i);
}
}
while (installers.Count > 0)
{
var next = installers.Pop();
if (_sourceTrace.Contains(next)) continue;
_sourceTrace.Add(next);
BuildReport.ReportingObject(next, () => ResolveInstaller(node, next, installers));
}
// Resolve any submenus
foreach (var virtualControl in node.Controls)
{
if (virtualControl.SubmenuNode != null)
{
virtualControl.SubmenuNode = ResolveNode(virtualControl.SubmenuNode.NodeKey);
}
}
}
finally
{
_sourceTrace = priorTrace;
}
return node;
}
private MenuNode ResolveSubmenuItem(ModularAvatarMenuItem item)
{
return BuildReport.ReportingObject(item, () =>
{
MenuNode node = new MenuNode(item);
_resolvedMenu[item] = node;
switch (item.MenuSource)
{
case SubmenuSource.External:
{
if (item.Control.subMenu != null)
{
node.Controls = ResolveNode(item.Control.subMenu).Controls;
}
break;
}
case SubmenuSource.Children:
{
var transformRoot = item.menuSource_otherObjectChildren != null
? item.menuSource_otherObjectChildren.transform
: item.transform;
foreach (Transform child in transformRoot)
{
if (!child.gameObject.activeSelf) continue;
var source = child.GetComponent<MenuSource>();
if (source == null) continue;
if (source is ModularAvatarMenuItem subItem)
{
var control = new VirtualControl(subItem.Control);
if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
control.SubmenuNode = ResolveNode(subItem);
}
control.name = subItem.gameObject.name;
node.Controls.Add(control);
}
else if (source is ModularAvatarMenuInstallTarget target && target.installer != null)
{
ResolveInstaller(node, target.installer, new Stack<ModularAvatarMenuInstaller>());
}
else
{
// TODO validation
}
}
break;
}
default:
// TODO validation
break;
}
return node;
});
}
private void ResolveInstaller(MenuNode node, ModularAvatarMenuInstaller installer,
Stack<ModularAvatarMenuInstaller> installers)
{
if (installer == null || !installer.enabled) return;
var menuSource = installer.GetComponent<MenuSource>();
if (menuSource == null)
{
var expMenu = installer.menuToAppend;
if (expMenu == null) return;
var controls = expMenu.controls;
if (controls == null) return;
foreach (var control in controls)
{
var virtualControl = new VirtualControl(control);
if (control.subMenu != null)
{
virtualControl.SubmenuNode = ResolveNode(control.subMenu);
}
node.Controls.Add(virtualControl);
}
if (_targetMenuToInstaller.TryGetValue(expMenu, out var subInstallers))
{
foreach (var subInstaller in subInstallers.Select(x => x).Reverse())
{
if (_installerToTargetComponent.ContainsKey(subInstaller)) continue;
installers.Push(subInstaller);
}
}
}
else if (menuSource is ModularAvatarMenuInstallTarget target)
{
if (target.installer != null)
{
installers.Push(target.installer);
}
}
else if (menuSource is ModularAvatarMenuItem item)
{
var virtualControl = new VirtualControl(item.Control);
virtualControl.name = item.gameObject.name;
node.Controls.Add(virtualControl);
if (virtualControl.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
virtualControl.SubmenuNode = ResolveNode(item);
}
}
else
{
BuildReport.Log(ReportLevel.Error, "virtual_menu.unknown_source_type",
strings: new object[] {menuSource.GetType().ToString()});
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 70cfde8e889a4057b14153c7021e16c8
timeCreated: 1676977210

View File

@ -0,0 +1,67 @@
using System.Collections.Generic;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core
{
/// <summary>
/// The menu install target includes the controls of the target menu installer at the point of reference.
/// Notably, this can include multiple controls.
///
/// One tricky aspect of this feature is that we need to disambiguate when a menu installer also cites a target menu.
/// Generally, if an installer is targeted by any menu install target (even if - especially if - disabled), we
/// ignore its install target configuration entirely.
///
/// We can also end up with a loop between install targets; in this case, we break the loop at an arbitrary point.
/// </summary>
internal class ModularAvatarMenuInstallTarget : MenuSource
{
public ModularAvatarMenuInstaller installer;
private static HashSet<MenuSource> _recursing = new HashSet<MenuSource>();
internal delegate T Returning<T>();
/**
* Temporarily clears the list of install targets we're recursing through. This is useful if we need to generate
* a submenu; these have their own recursion stack, and we shouldn't truncate the set of controls registered on
* a different submenu that happens to transclude the same point.
*/
internal static T PushRecursing<T>(Returning<T> callback)
{
HashSet<MenuSource> oldRecursing = _recursing;
_recursing = new HashSet<MenuSource>();
try
{
return callback();
}
finally
{
_recursing = oldRecursing;
}
}
internal override VRCExpressionsMenu.Control[] GenerateMenu()
{
if (installer == null) return new VRCExpressionsMenu.Control[] { };
_recursing.Add(this);
try
{
var source = installer.GetComponent<MenuSource>();
if (source != null)
{
return source.GenerateMenu();
}
else
{
// ReSharper disable once Unity.NoNullPropagation
return installer.menuToAppend?.controls?.ToArray() ?? new VRCExpressionsMenu.Control[] { };
}
}
finally
{
_recursing.Remove(this);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1fad1419b52a42ae89b0df52eb861e47
timeCreated: 1676976513

View File

@ -22,17 +22,15 @@ namespace nadena.dev.modular_avatar.core
{
External,
Children,
MenuInstaller,
OtherMenuItem,
}
public class MAMenuItem : MenuSource
[AddComponentMenu("Modular Avatar/MA Menu Item")]
public class ModularAvatarMenuItem : MenuSource
{
public VRCExpressionsMenu.Control Control;
public SubmenuSource MenuSource;
public ModularAvatarMenuInstaller menuSource_installer;
public MenuSource menuSource_otherSource;
public GameObject menuSource_otherObjectChildren;
internal override VRCExpressionsMenu.Control[] GenerateMenu()
@ -75,6 +73,7 @@ namespace nadena.dev.modular_avatar.core
break;
}
/*
case SubmenuSource.MenuInstaller:
controls = menuSource_installer.installTargetMenu?.controls?.ToList();
break;
@ -95,6 +94,7 @@ namespace nadena.dev.modular_avatar.core
_recursing = false;
}
}
*/
}
if (controls == null)